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.CookieManager import android.webkit.GeolocationPermissions import android.webkit.PermissionRequest import android.webkit.RenderProcessGoneDetail import android.webkit.SslErrorHandler import android.webkit.URLUtil import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebStorage import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button import android.widget.EditText import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.ScrollView import android.widget.TextView import android.widget.Toast 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 var 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>? = null private var capture: Uri? = null private var pendingDownload: String? = null private var generation = 0 private val uploadSelection = SelectionGate() private val downloadSelection = SelectionGate() private val loadTimeout = Runnable { showError("The server is taking too long. Check your connection or try again.") } private val localNetworkPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> if (granted) { toast("Private-network access allowed. Retry your server connection.") } else { toast("Private-network access was not granted. Public HTTPS servers can still be used.") } } private val picker = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> val accepted = uploadSelection.consume(generation) val callback = fileCallback fileCallback = null val uris = if (!accepted || 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()) revokeCapture() capture = null } private val saveDocument = registerForActivityResult( ActivityResultContracts.CreateDocument("application/octet-stream"), ) { destination -> val accepted = downloadSelection.consume(generation) val url = pendingDownload pendingDownload = null if (!accepted) return@registerForActivityResult val current = server if (destination != null && url != null && current != null && BlobTransfer.owns(current, url) ) { val view = web ?: return@registerForActivityResult val epoch = generation BlobTransfer.save( current, view, url, contentResolver, destination, worker, { !isDestroyed && epoch == generation }, ) { success -> if (!success) runCatching { contentResolver.delete(destination, null, null) } if (!isDestroyed && epoch == generation ) { toast( if (success) "Export saved" else "Export failed or exceeded the 25 MB limit.", ) } } } else 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 if (!success) runCatching { contentResolver.delete(destination, null, null) } runOnUiThread { if (!isDestroyed && epoch == generation) { if (success) { toast("Download saved") } else { toast( "Download failed. Check your connection and sign-in, then retry.", ) } } } } } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState?.getBoolean("uploadOutstanding") == true) uploadSelection.restoreOutstanding() if (savedInstanceState?.getBoolean("downloadOutstanding") == true) downloadSelection.restoreOutstanding() // 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) { moveTaskToBack(true) } 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) localNetworkButton()?.let { form.addView(it) } 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) BlobTransfer.install(view, current) CookieManager.getInstance().setAcceptCookie(true) CookieManager.getInstance().setAcceptThirdPartyCookies(view, false) view.webViewClient = object : WebViewClient() { override fun shouldInterceptRequest(v: WebView, request: WebResourceRequest): WebResourceResponse? { if (!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?) { if (!current.owns(url)) { v.stopLoading() external(url) return } 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>, params: FileChooserParams): Boolean { if (!current.owns(v.url.orEmpty())) return false if (!uploadSelection.begin(generation)) { callback.onReceiveValue(null) toast("Finish or cancel the existing system file picker first.") return true } fileCallback?.onReceiveValue(null) fileCallback = callback runCatching { 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) clipData = android.content.ClipData.newRawUri("Yuvomi photo", capture) addFlags( Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION, ) }, ), ) } picker.launch(chooser) }.onFailure { uploadSelection.consume(generation) fileCallback?.onReceiveValue(null) fileCallback = null toast("No compatible file picker is available.") } return true } } view.setDownloadListener { url, _, disposition, mime, _ -> if (!current.owns(url) && !BlobTransfer.owns(current, url) ) { toast("Only downloads from your server are supported.") } else if (pendingDownload == null && downloadSelection.begin(generation)) { pendingDownload = url val name = URLUtil.guessFileName( url, disposition, mime, ).replace(Regex("[^a-zA-Z0-9._ -]"), "_").take(120) runCatching { saveDocument.launch(name.ifBlank { "download" }) }.onFailure { downloadSelection.consume(generation) pendingDownload = null toast("No compatible document provider is available.") } } } 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) { handler.removeCallbacks(loadTimeout) progress.visibility = View.GONE 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 onPageStarted(v: WebView, url: String, favicon: Bitmap?) { authDialog?.setTitle( "Sign in: ${runCatching { URI(url).host }.getOrNull().orEmpty()}", ) } 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() }) localNetworkButton()?.let { addView(it) } } 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++ worker.shutdownNow() worker = Executors.newSingleThreadExecutor() authDialog?.dismiss() releaseWeb() val captures = File(cacheDir, "captures") val capturesErased = !captures.exists() || captures.listFiles()?.all { !it.exists() || it.delete() } == true if (capturesErased && 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. val reset = getSystemService( android.app.ActivityManager::class.java, ).clearApplicationUserData() if (!reset) { toast("Local data could not be erased. Clear this app's storage in Android settings before switching servers.") finish() } } } .setNeutralButton("Open server in browser") { _, _ -> server?.let { openBrowser(it.origin) } } .setNegativeButton("Continue", null).show() } private fun localNetworkButton(): Button? { if (android.os.Build.VERSION.SDK_INT < 37 || checkSelfPermission(android.Manifest.permission.ACCESS_LOCAL_NETWORK) == android.content.pm.PackageManager.PERMISSION_GRANTED ) { return null } return button("Allow private-network servers") { AlertDialog.Builder(this).setTitle("Private server access") .setMessage( "Android requires permission to connect to a Yuvomi server on your local network. The app connects only to your configured server; it does not scan for devices. Public internet servers do not need this permission.", ) .setPositiveButton("Continue") { _, _ -> localNetworkPermission.launch(android.Manifest.permission.ACCESS_LOCAL_NETWORK) } .setNegativeButton("Cancel", null).show() } } private fun releaseWeb() { handler.removeCallbacks(loadTimeout) fileCallback?.onReceiveValue(null) fileCallback = null pendingDownload = null revokeCapture() capture = null web?.let { view -> BlobTransfer.forget(view) view.stopLoading() (view.parent as? ViewGroup)?.removeView(view) view.removeAllViews() view.destroy() } web = null } private fun revokeCapture() { capture?.let { revokeUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION) } } override fun onSaveInstanceState(outState: Bundle) { outState.putBoolean("uploadOutstanding", uploadSelection.outstanding) outState.putBoolean("downloadOutstanding", downloadSelection.outstanding) super.onSaveInstanceState(outState) } 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() } }