SHA256
feat(android): implement secure self-hosted companion and release builds
Add native HTTPS onboarding, origin-bound WebView, isolated web authentication, persistent sessions, upload/camera picker, bounded authenticated downloads and recovery. Validate JVM policy tests, debug APK, lint and unsigned release APK/AAB. Build outputs can be relocated for WSL.
This commit is contained in:
1 parent
fbcc220869
commit
ed8d5cb1c1
23 files changed
+1049
No files matched your search
@@ -0,0 +1,29 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:usesCleartextTraffic="false"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@drawable/ic_launcher"
|
||||
android:theme="@style/AppTheme"
|
||||
android:supportsRtl="true">
|
||||
<activity android:name=".MainActivity" android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden|uiMode"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<provider android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.files" android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,36 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.net.Uri
|
||||
import java.net.URI
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
|
||||
object FileTransfer {
|
||||
/** Never forwards session cookies across a redirect, even to another HTTPS origin. */
|
||||
fun save(server: ServerAddress, url: String, cookie: String?, resolver: ContentResolver, destination: Uri) {
|
||||
require(server.owns(url))
|
||||
val connection = URI(url).toURL().openConnection() as HttpsURLConnection
|
||||
try {
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.connectTimeout = 15_000; connection.readTimeout = 20_000
|
||||
cookie?.let { connection.setRequestProperty("Cookie", it) }
|
||||
require(connection.responseCode == 200) { "Download refused" }
|
||||
require(connection.contentLengthLong <= 100L * 1024 * 1024) { "Download too large" }
|
||||
connection.inputStream.use { input ->
|
||||
requireNotNull(resolver.openOutputStream(destination, "w")).use { output ->
|
||||
val buffer = ByteArray(8192)
|
||||
var total = 0L
|
||||
val deadline = System.nanoTime() + 120_000_000_000L
|
||||
while (true) {
|
||||
check(!Thread.currentThread().isInterrupted && System.nanoTime() < deadline)
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
total += count
|
||||
require(total <= 100L * 1024 * 1024)
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally { connection.disconnect() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.net.http.SslError
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.MediaStore
|
||||
import android.text.InputType
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.view.WindowManager
|
||||
import android.webkit.*
|
||||
import android.widget.*
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.core.content.edit
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.webkit.WebStorageCompat
|
||||
import androidx.webkit.WebViewFeature
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
import java.util.concurrent.Executors
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val worker = Executors.newSingleThreadExecutor()
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private val preferences by lazy { getSharedPreferences("server", MODE_PRIVATE) }
|
||||
private var server: ServerAddress? = null
|
||||
private var web: WebView? = null
|
||||
private var authDialog: AlertDialog? = null
|
||||
private lateinit var root: LinearLayout
|
||||
private lateinit var content: FrameLayout
|
||||
private lateinit var progress: ProgressBar
|
||||
private var fileCallback: ValueCallback<Array<Uri>>? = null
|
||||
private var capture: Uri? = null
|
||||
private var pendingDownload: String? = null
|
||||
private var generation = 0
|
||||
private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") }
|
||||
|
||||
private val picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val callback = fileCallback
|
||||
fileCallback = null
|
||||
val uris = if (result.resultCode != RESULT_OK) null else {
|
||||
val data = result.data
|
||||
data?.clipData?.let { clip -> Array(clip.itemCount.coerceAtMost(50)) { clip.getItemAt(it).uri } }
|
||||
?: data?.data?.let { arrayOf(it) } ?: capture?.let { arrayOf(it) }
|
||||
}
|
||||
callback?.onReceiveValue(uris?.filter { it.scheme == "content" }?.toTypedArray())
|
||||
capture = null
|
||||
}
|
||||
private val saveDocument = registerForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { destination ->
|
||||
val url = pendingDownload
|
||||
pendingDownload = null
|
||||
val current = server
|
||||
if (destination != null && url != null && current != null && current.owns(url)) {
|
||||
val cookies = CookieManager.getInstance().getCookie(url)
|
||||
val epoch = generation
|
||||
worker.execute {
|
||||
val success = runCatching { FileTransfer.save(current, url, cookies, contentResolver, destination) }.isSuccess
|
||||
runOnUiThread {
|
||||
if (!isDestroyed && epoch == generation) {
|
||||
if (success) toast("Download saved") else {
|
||||
runCatching { contentResolver.delete(destination, null, null) }
|
||||
toast("Download failed. Check your connection and sign-in, then retry.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Household data is hidden from screenshots and the recent-apps preview.
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||
WebView.setWebContentsDebuggingEnabled(false)
|
||||
root = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
|
||||
ViewCompat.setOnApplyWindowInsetsListener(root) { view, insets ->
|
||||
val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime())
|
||||
view.setPadding(bars.left, bars.top, bars.right, bars.bottom)
|
||||
insets
|
||||
}
|
||||
setContentView(root)
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (web?.canGoBack() == true) web?.goBack()
|
||||
else if (web != null) settings()
|
||||
else { isEnabled = false; onBackPressedDispatcher.onBackPressed() }
|
||||
}
|
||||
})
|
||||
File(cacheDir, "captures").listFiles()?.filter { it.lastModified() < System.currentTimeMillis() - 86_400_000 }
|
||||
?.forEach { it.delete() }
|
||||
server = preferences.getString("origin", null)?.let { runCatching { ServerAddress.parse(it) }.getOrNull() }
|
||||
if (server == null) onboarding() else browser()
|
||||
}
|
||||
|
||||
private fun column(): LinearLayout = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(24), dp(20), dp(24), dp(20))
|
||||
}
|
||||
private fun text(value: String, size: Float = 17f) = TextView(this).apply {
|
||||
text = value; textSize = size; setPadding(0, dp(8), 0, dp(8))
|
||||
}
|
||||
private fun button(label: String, action: () -> Unit) = Button(this).apply {
|
||||
text = label; isAllCaps = false; minHeight = dp(48); setOnClickListener { action() }
|
||||
}
|
||||
private fun dp(value: Int) = (value * resources.displayMetrics.density).toInt()
|
||||
|
||||
private fun onboarding() {
|
||||
root.removeAllViews()
|
||||
val form = column()
|
||||
form.addView(text("Your home, connected", 30f))
|
||||
form.addView(text("Connect to your own Yuvomi server. Your data stays with your household."))
|
||||
val address = EditText(this).apply {
|
||||
hint = "https://family.example.com"
|
||||
inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
|
||||
setSingleLine(true)
|
||||
contentDescription = "Yuvomi server URL"
|
||||
setText(server?.origin.orEmpty())
|
||||
id = View.generateViewId()
|
||||
}
|
||||
val label = text("HTTPS required. Custom ports supported. Use a dedicated hostname, not a subpath.")
|
||||
label.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
lateinit var connect: Button
|
||||
connect = button("Connect securely") {
|
||||
val candidate = runCatching { ServerAddress.parse(address.text.toString()) }.getOrElse {
|
||||
address.error = it.message; return@button
|
||||
}
|
||||
connect.isEnabled = false; address.isEnabled = false; label.setText(R.string.checking_server)
|
||||
val epoch = generation
|
||||
worker.execute {
|
||||
val result = runCatching { ServerProbe.check(candidate) }
|
||||
runOnUiThread {
|
||||
if (isDestroyed || epoch != generation) return@runOnUiThread
|
||||
connect.isEnabled = true; address.isEnabled = true
|
||||
if (result.isSuccess) {
|
||||
server = candidate
|
||||
preferences.edit { putString("origin", candidate.origin); remove("path") }
|
||||
browser()
|
||||
} else {
|
||||
// Exception details may contain URL parameters; never expose them.
|
||||
label.setText(R.string.probe_failed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
form.addView(address)
|
||||
form.addView(label)
|
||||
form.addView(connect)
|
||||
form.addView(text("Independent community app. No telemetry or mandatory cloud service.", 14f))
|
||||
root.addView(ScrollView(this).apply { addView(form) })
|
||||
}
|
||||
|
||||
private fun browser() {
|
||||
val current = server ?: return onboarding()
|
||||
root.removeAllViews()
|
||||
val toolbar = LinearLayout(this)
|
||||
toolbar.addView(button("Server settings") { settings() })
|
||||
toolbar.addView(button("Share server") {
|
||||
startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"; putExtra(Intent.EXTRA_TEXT, current.origin)
|
||||
}, "Share server address"))
|
||||
})
|
||||
root.addView(toolbar)
|
||||
progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply {
|
||||
contentDescription = "Loading Yuvomi"; max = 100
|
||||
}
|
||||
root.addView(progress, LinearLayout.LayoutParams(-1, dp(4)))
|
||||
content = FrameLayout(this)
|
||||
root.addView(content, LinearLayout.LayoutParams(-1, 0, 1f))
|
||||
val view = WebView(this)
|
||||
web = view
|
||||
secure(view)
|
||||
CookieManager.getInstance().setAcceptCookie(true)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(view, false)
|
||||
view.webViewClient = object : WebViewClient() {
|
||||
override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? {
|
||||
if (!request.isForMainFrame && !current.owns(request.url.toString())) {
|
||||
return WebResourceResponse("text/plain", "UTF-8", 403, "Blocked", emptyMap(), ByteArrayInputStream(ByteArray(0)))
|
||||
}
|
||||
return null
|
||||
}
|
||||
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
|
||||
val url = request.url.toString()
|
||||
if (current.owns(url)) return false
|
||||
if (request.isForMainFrame) external(url)
|
||||
return true
|
||||
}
|
||||
override fun onPageStarted(v: WebView, url: String, icon: Bitmap?) {
|
||||
progress.visibility = View.VISIBLE
|
||||
handler.removeCallbacks(loadTimeout); handler.postDelayed(loadTimeout, 30_000)
|
||||
}
|
||||
override fun onPageFinished(v: WebView, url: String) {
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
progress.visibility = View.GONE
|
||||
CookieManager.getInstance().flush()
|
||||
if (current.owns(url)) {
|
||||
val path = URI(url).rawPath.orEmpty()
|
||||
if (!listOf("auth", "reset", "invite", "login").any { path.contains(it, true) }) {
|
||||
preferences.edit { putString("path", path) }
|
||||
}
|
||||
}
|
||||
}
|
||||
override fun onReceivedError(v: WebView, r: WebResourceRequest, e: WebResourceError) {
|
||||
if (r.isForMainFrame) showError("Unable to connect. Cached content may still be available. Check your network or VPN.")
|
||||
}
|
||||
override fun onReceivedHttpError(v: WebView, r: WebResourceRequest, e: WebResourceResponse) {
|
||||
if (r.isForMainFrame && e.statusCode >= 400) showError("The server is unavailable (HTTP ${e.statusCode}).")
|
||||
}
|
||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) {
|
||||
h.cancel(); showError("The server certificate could not be verified. Contact your server administrator.")
|
||||
}
|
||||
override fun onRenderProcessGone(v: WebView, detail: RenderProcessGoneDetail): Boolean {
|
||||
releaseWeb(); browser(); toast("Yuvomi recovered after a browser interruption."); return true
|
||||
}
|
||||
}
|
||||
view.webChromeClient = object : WebChromeClient() {
|
||||
override fun onProgressChanged(v: WebView, value: Int) { progress.progress = value }
|
||||
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
|
||||
override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
|
||||
callback.invoke(origin, false, false)
|
||||
}
|
||||
override fun onShowFileChooser(v: WebView, callback: ValueCallback<Array<Uri>>, params: FileChooserParams): Boolean {
|
||||
if (!current.owns(v.url.orEmpty())) return false
|
||||
fileCallback?.onReceiveValue(null)
|
||||
fileCallback = callback
|
||||
val types = params.acceptTypes.filter { it.matches(Regex("[a-zA-Z0-9.+*-]+/[a-zA-Z0-9.+*-]+")) }
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = types.singleOrNull() ?: "*/*"
|
||||
if (types.size > 1) putExtra(Intent.EXTRA_MIME_TYPES, types.toTypedArray())
|
||||
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, params.mode == FileChooserParams.MODE_OPEN_MULTIPLE)
|
||||
}
|
||||
val chooser = Intent.createChooser(intent, "Select a file")
|
||||
if (types.any { it.startsWith("image/") }) {
|
||||
val directory = File(cacheDir, "captures").apply { mkdirs() }
|
||||
capture = FileProvider.getUriForFile(this@MainActivity, "$packageName.files", File.createTempFile("capture-", ".jpg", directory))
|
||||
chooser.putExtra(Intent.EXTRA_INITIAL_INTENTS, arrayOf(Intent(MediaStore.ACTION_IMAGE_CAPTURE).apply {
|
||||
putExtra(MediaStore.EXTRA_OUTPUT, capture)
|
||||
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}))
|
||||
}
|
||||
runCatching { picker.launch(chooser) }.onFailure {
|
||||
fileCallback?.onReceiveValue(null); fileCallback = null; toast("No compatible file picker is available.")
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
view.setDownloadListener { url, _, disposition, mime, _ ->
|
||||
if (!current.owns(url)) toast("Only HTTPS downloads from your server are supported.")
|
||||
else if (pendingDownload == null) {
|
||||
pendingDownload = url
|
||||
val name = URLUtil.guessFileName(url, disposition, mime).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120)
|
||||
saveDocument.launch(name.ifBlank { "download" })
|
||||
}
|
||||
}
|
||||
content.addView(view, FrameLayout.LayoutParams(-1, -1))
|
||||
val path = preferences.getString("path", "/").orEmpty()
|
||||
view.loadUrl(current.origin + if (path.startsWith("/") && !path.startsWith("//")) path else "/")
|
||||
}
|
||||
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
private fun secure(view: WebView) {
|
||||
view.settings.apply {
|
||||
javaScriptEnabled = true; domStorageEnabled = true
|
||||
allowFileAccess = false; allowContentAccess = false
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
|
||||
javaScriptCanOpenWindowsAutomatically = false
|
||||
setSupportMultipleWindows(false)
|
||||
mediaPlaybackRequiresUserGesture = true
|
||||
safeBrowsingEnabled = true
|
||||
setGeolocationEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun external(value: String) {
|
||||
val uri = runCatching { URI(value) }.getOrNull() ?: return
|
||||
if (uri.scheme != "https" || uri.host.isNullOrBlank() || uri.rawUserInfo != null) {
|
||||
toast("This link type is blocked for your security."); return
|
||||
}
|
||||
AlertDialog.Builder(this).setTitle("Leave your Yuvomi server?")
|
||||
.setMessage("Destination: ${uri.host}\n\nOpen normal links in your browser. For SSO, use the isolated sign-in window only if you trust this provider. Providers that require a system browser cannot transfer a session back to this app.")
|
||||
.setPositiveButton("Browser") { _, _ -> openBrowser(value) }
|
||||
.setNeutralButton("Sign-in window") { _, _ -> authentication(value) }
|
||||
.setNegativeButton("Cancel", null).show()
|
||||
}
|
||||
|
||||
private fun authentication(value: String) {
|
||||
if (authDialog != null) return
|
||||
val auth = WebView(this)
|
||||
secure(auth)
|
||||
CookieManager.getInstance().setAcceptThirdPartyCookies(auth, false)
|
||||
auth.webChromeClient = object : WebChromeClient() {
|
||||
override fun onPermissionRequest(request: PermissionRequest) { request.deny() }
|
||||
}
|
||||
auth.webViewClient = object : WebViewClient() {
|
||||
override fun shouldOverrideUrlLoading(v: WebView, request: WebResourceRequest): Boolean {
|
||||
val uri = request.url
|
||||
return uri.scheme != "https" || !uri.userInfo.isNullOrEmpty()
|
||||
}
|
||||
override fun onReceivedSslError(v: WebView, h: SslErrorHandler, e: SslError) { h.cancel() }
|
||||
override fun onPageFinished(v: WebView, url: String) {
|
||||
if (server?.owns(url) == true && !URI(url).path.startsWith("/api/")) {
|
||||
CookieManager.getInstance().flush()
|
||||
authDialog?.dismiss(); web?.loadUrl(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
authDialog = AlertDialog.Builder(this).setTitle("Isolated web sign-in")
|
||||
.setView(auth).setNegativeButton("Close", null).create().also { dialog ->
|
||||
dialog.setOnDismissListener {
|
||||
auth.stopLoading(); auth.removeAllViews(); auth.destroy(); authDialog = null
|
||||
}
|
||||
dialog.show()
|
||||
dialog.window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
}
|
||||
auth.loadUrl(value)
|
||||
}
|
||||
|
||||
private fun openBrowser(value: String) {
|
||||
runCatching { startActivity(Intent(Intent.ACTION_VIEW, value.toUri()).addCategory(Intent.CATEGORY_BROWSABLE)) }
|
||||
.onFailure { toast("No browser is available.") }
|
||||
}
|
||||
|
||||
private fun showError(message: String) {
|
||||
if (isDestroyed || web == null) return
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
progress.visibility = View.GONE
|
||||
if (content.childCount > 1) return
|
||||
val panel = column().apply {
|
||||
addView(text("Connection interrupted", 25f)); addView(text(message))
|
||||
addView(button("Retry") { content.removeView(this); web?.reload() })
|
||||
addView(button("Keep viewing") { content.removeView(this) })
|
||||
addView(button("Server settings") { settings() })
|
||||
}
|
||||
val dark = resources.configuration.uiMode and android.content.res.Configuration.UI_MODE_NIGHT_MASK == android.content.res.Configuration.UI_MODE_NIGHT_YES
|
||||
panel.setBackgroundColor(if (dark) 0xff191816.toInt() else 0xfff5f3ed.toInt())
|
||||
content.addView(panel, FrameLayout.LayoutParams(-1, -1))
|
||||
panel.accessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_POLITE
|
||||
ViewCompat.setAccessibilityPaneTitle(panel, "Connection interrupted")
|
||||
}
|
||||
|
||||
private fun settings() {
|
||||
AlertDialog.Builder(this).setTitle("Server settings")
|
||||
.setMessage("${server?.origin.orEmpty()}\n\nSwitching servers erases all app cookies, storage and cached household data. Downloads saved outside the app remain. This does not revoke the server session; sign out in Yuvomi first where possible.")
|
||||
.setPositiveButton("Erase and change server") { _, _ ->
|
||||
generation++
|
||||
authDialog?.dismiss(); releaseWeb()
|
||||
if (WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA)) {
|
||||
WebStorageCompat.deleteBrowsingData(WebStorage.getInstance()) {
|
||||
CookieManager.getInstance().removeAllCookies {
|
||||
CookieManager.getInstance().flush()
|
||||
preferences.edit { clear() }; server = null; onboarding()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Older engines cannot reliably erase CacheStorage/IndexedDB.
|
||||
// OS reset is complete and scoped to this application, but closes it.
|
||||
getSystemService(android.app.ActivityManager::class.java).clearApplicationUserData()
|
||||
}
|
||||
}
|
||||
.setNeutralButton("Open server in browser") { _, _ -> server?.let { openBrowser(it.origin) } }
|
||||
.setNegativeButton("Continue", null).show()
|
||||
}
|
||||
|
||||
private fun releaseWeb() {
|
||||
handler.removeCallbacks(loadTimeout)
|
||||
fileCallback?.onReceiveValue(null); fileCallback = null
|
||||
web?.let { view ->
|
||||
view.stopLoading()
|
||||
(view.parent as? ViewGroup)?.removeView(view)
|
||||
view.removeAllViews(); view.destroy()
|
||||
}
|
||||
web = null
|
||||
}
|
||||
private fun toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_LONG).show()
|
||||
override fun onPause() { web?.onPause(); CookieManager.getInstance().flush(); super.onPause() }
|
||||
override fun onResume() { super.onResume(); web?.onResume() }
|
||||
override fun onDestroy() {
|
||||
generation++; authDialog?.dismiss(); releaseWeb(); worker.shutdownNow(); super.onDestroy()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
|
||||
/** One origin is the trust boundary. Paths are not supported by upstream. */
|
||||
class ServerAddress private constructor(val origin: String) {
|
||||
fun owns(value: String): Boolean = runCatching {
|
||||
val uri = URI(value)
|
||||
uri.rawUserInfo == null && uri.scheme.equals("https", true) &&
|
||||
uri.host.equals(URI(origin).host, true) && effectivePort(uri) == effectivePort(URI(origin))
|
||||
}.getOrDefault(false)
|
||||
|
||||
companion object {
|
||||
fun parse(input: String): ServerAddress {
|
||||
val uri = runCatching { URI(input.trim()) }.getOrNull()
|
||||
?: throw IllegalArgumentException("Enter a valid HTTPS server URL.")
|
||||
require(uri.scheme.equals("https", true) && !uri.host.isNullOrBlank()) {
|
||||
"Use HTTPS with a valid server hostname."
|
||||
}
|
||||
require(uri.rawUserInfo == null && uri.rawQuery == null && uri.rawFragment == null) {
|
||||
"Remove credentials, query parameters and fragments."
|
||||
}
|
||||
require(uri.rawPath.isNullOrEmpty() || uri.rawPath == "/") {
|
||||
"Yuvomi requires a dedicated hostname, not a subpath."
|
||||
}
|
||||
require(uri.port == -1 || uri.port in 1..65535) { "The port must be between 1 and 65535." }
|
||||
val port = if (uri.port == -1 || uri.port == 443) "" else ":${uri.port}"
|
||||
return ServerAddress("https://${uri.host.lowercase(Locale.ROOT)}$port")
|
||||
}
|
||||
|
||||
private fun effectivePort(uri: URI) = if (uri.port == -1) 443 else uri.port
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package community.yuvomi.mobile
|
||||
|
||||
import org.json.JSONObject
|
||||
import java.net.URI
|
||||
import javax.net.ssl.HttpsURLConnection
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
object ServerProbe {
|
||||
/** No cookies, no automatic redirects, bounded response and deadlines. */
|
||||
fun check(server: ServerAddress) {
|
||||
val version = json(server, "/api/v1/version")
|
||||
require(version.opt("app_name") is String && version.opt("setup_required") is Boolean &&
|
||||
version.opt("password_reset_enabled") is Boolean) {
|
||||
"This server does not expose compatible Yuvomi bootstrap metadata."
|
||||
}
|
||||
val auth = json(server, "/api/v1/auth/oidc/config")
|
||||
require(auth.opt("enabled") is Boolean && auth.opt("password_login_enabled") is Boolean) {
|
||||
"This server does not expose compatible Yuvomi authentication metadata."
|
||||
}
|
||||
}
|
||||
|
||||
private fun json(server: ServerAddress, path: String): JSONObject {
|
||||
val connection = URI(server.origin + path).toURL().openConnection() as HttpsURLConnection
|
||||
try {
|
||||
connection.connectTimeout = 10_000
|
||||
connection.readTimeout = 10_000
|
||||
connection.instanceFollowRedirects = false
|
||||
connection.setRequestProperty("Accept", "application/json")
|
||||
require(connection.responseCode == 200) {
|
||||
"Server unavailable or authentication proxy blocked the connection (HTTP ${connection.responseCode})."
|
||||
}
|
||||
val bytes = connection.inputStream.use { input ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(4096)
|
||||
val deadline = System.nanoTime() + 20_000_000_000L
|
||||
while (true) {
|
||||
check(System.nanoTime() < deadline)
|
||||
val count = input.read(buffer)
|
||||
if (count < 0) break
|
||||
require(output.size() + count <= 65_536) { "Server metadata is too large." }
|
||||
output.write(buffer, 0, count)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
return JSONObject(String(bytes, Charsets.UTF_8))
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="108dp" android:height="108dp" android:viewportWidth="108" android:viewportHeight="108">
|
||||
<path android:fillColor="#6C3AED" android:pathData="M0,0h108v108h-108z" />
|
||||
<path android:strokeColor="#FFFFFF" android:strokeWidth="9" android:strokeLineCap="round" android:fillColor="@android:color/transparent" android:pathData="M32,29L54,54L76,29M54,54L54,80" />
|
||||
</vector>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#A78BFA</item>
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowBackground">#191816</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">Yuvomi Mobile</string>
|
||||
<string name="checking_server">Checking your server…</string>
|
||||
<string name="probe_failed">Cannot verify a compatible Yuvomi server. Check the URL, TLS certificate, VPN, and reverse-proxy access.</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,8 @@
|
||||
<resources>
|
||||
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
|
||||
<item name="android:fontFamily">sans</item>
|
||||
<item name="android:colorAccent">#6C3AED</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowBackground">#F5F3ED</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,16 @@
|
||||
<data-extraction-rules>
|
||||
<cloud-backup disableIfNoEncryptionCapabilities="true">
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</cloud-backup>
|
||||
<device-transfer>
|
||||
<exclude domain="root" path="." />
|
||||
<exclude domain="file" path="." />
|
||||
<exclude domain="database" path="." />
|
||||
<exclude domain="sharedpref" path="." />
|
||||
<exclude domain="external" path="." />
|
||||
</device-transfer>
|
||||
</data-extraction-rules>
|
||||
@@ -0,0 +1,3 @@
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<cache-path name="captures" path="captures/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<base-config cleartextTrafficPermitted="false">
|
||||
<trust-anchors><certificates src="system" /></trust-anchors>
|
||||
</base-config>
|
||||
</network-security-config>
|
||||
Reference in new issue
Block a user