feat(android): replace WebView with tested native Compose API slice

Add real native onboarding/auth/dashboard/tasks/notes/shopping, origin-bound HTTPS and encrypted AndroidKeyStore session persistence. Archive the superseded browser renderer and tests. Verify 10 JVM tests, 6 native API-35 emulator tests, lintDebug/lintRelease and debug/unsigned release APK/AAB builds; the real upstream suite passes 7 tests and exports a nonblank native fixture screenshot. Tink host-tool update removes the old generated-protobuf warning. Full module/visual parity remains explicitly incomplete.
This commit is contained in:
Yuvomi Mobile Contributors committed 2026-10-21 09:12:47 +02:00
1 parent 81973c3c45
commit 8c480ddca3
28 files changed
+2628 -742

No files matched your search

+689
View File
@@ -0,0 +1,689 @@
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<Array<Uri>>? = 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<Array<Uri>>, 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()
}
}