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

+8
View File
@@ -0,0 +1,8 @@
# Superseded WebView prototype
These files are retained as historical reference, not shipped application code.
The Android browser tests in `legacy/android/tests` and Apple BrowserModel tests
describe this prototype, not native feature parity.
Android now selects the explicitly named native test source directories instead.
Do not restore a WebView as the application surface or count old browser tests as
native-client validation.
+16
View File
@@ -0,0 +1,16 @@
package community.yuvomi.mobile
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class BlobOriginsTest {
@Test fun blobDownloadsCannotCrossOriginsOrDowngradeTls() {
val server = ServerAddress.parse("https://family.example")
assertTrue(BlobTransfer.owns(server, "blob:https://family.example/unique-id"))
listOf(
"blob:https://evil.example/id", "blob:http://family.example/id",
"blob:https://family.example.evil.test/id", "data:text/plain,hi",
).forEach { assertFalse(it, BlobTransfer.owns(server, it)) }
}
}
+165
View File
@@ -0,0 +1,165 @@
package community.yuvomi.mobile
import android.content.ContentResolver
import android.net.Uri
import android.os.Handler
import android.os.Looper
import android.util.Base64
import android.webkit.WebView
import androidx.webkit.WebViewCompat
import androidx.webkit.WebViewFeature
import java.io.OutputStream
import java.util.UUID
import java.util.WeakHashMap
import java.util.concurrent.Executor
import org.json.JSONObject
/** Same-origin, user-selected blob exports. No JS bridge or CSP/network bypass. */
object BlobTransfer {
private val stores = WeakHashMap<WebView, String>()
fun owns(server: ServerAddress, value: String): Boolean = value.startsWith("blob:") && server.owns(value.substring(5))
fun install(web: WebView, server: ServerAddress) {
if (!WebViewFeature.isFeatureSupported(WebViewFeature.DOCUMENT_START_SCRIPT)) return
val key = "__yuvomi_blobs_${UUID.randomUUID().toString().replace("-", "") }"
stores[web] = key
val script = """
(function(){if(window.top!==window)return;
const entries=new Map();let total=0;const limit=25*1024*1024;
const original=URL.createObjectURL.bind(URL);
URL.createObjectURL=function(blob){const url=original(blob);
if(blob instanceof Blob && blob.size<=limit){
while(entries.size && (total+blob.size>limit || entries.size>=8)){
const first=entries.keys().next().value;total-=entries.get(first).size;entries.delete(first);
}
entries.set(url,blob);total+=blob.size;
}return url;};
Object.defineProperty(window,${JSONObject.quote(key)},{value:{take:function(url){
const blob=entries.get(url);if(blob){total-=blob.size;entries.delete(url);}return blob;
}}});
})();
""".trimIndent()
// Retain a bounded Blob reference even if upstream revokes the object URL
// immediately after clicking its download anchor. Never fetch a blob URL:
// upstream's CSP correctly refuses that operation in a WebView.
WebViewCompat.addDocumentStartJavaScript(web, script, setOf(server.origin))
}
fun forget(web: WebView) {
stores.remove(web)
}
fun save(
server: ServerAddress,
web: WebView,
value: String,
resolver: ContentResolver,
destination: Uri,
worker: Executor,
authorized: () -> Boolean,
completed: (Boolean) -> Unit,
) {
val store = stores[web]
if (store == null || !authorized() || !server.owns(web.url.orEmpty()) || !owns(server, value)) {
completed(false)
return
}
val handler = Handler(Looper.getMainLooper())
val key = JSONObject.quote("__yuvomi_export_${UUID.randomUUID().toString().replace("-", "")}")
val maxBytes = 25 * 1024 * 1024
var output: OutputStream? = null
var offset = 0
var finished = false
lateinit var expiry: Runnable
fun allowed() = authorized() && server.owns(web.url.orEmpty())
fun finish(success: Boolean) {
if (finished) return
finished = true
handler.removeCallbacks(expiry)
if (runCatching { allowed() }.getOrDefault(false)) {
runCatching { web.evaluateJavascript("delete window[$key]", null) }
}
val close = Runnable {
val closed = runCatching { output?.close() }.isSuccess
handler.post { completed(success && closed) }
}
runCatching { worker.execute(close) }.onFailure { close.run() }
}
expiry = Runnable { finish(false) }
lateinit var pull: Runnable
pull = Runnable {
if (!runCatching { allowed() }.getOrDefault(false)) {
finish(false)
return@Runnable
}
val script = """
(function(){const x=window[$key];
if(!x || x.state!=='ready')return {state:x?x.state:'failed'};
const b=x.bytes;if(b.length>$maxBytes)return {state:'failed'};
const end=Math.min($offset+32768,b.length);
return {state:'ready',data:btoa(String.fromCharCode.apply(null,b.subarray($offset,end))),done:end===b.length};})()
""".trimIndent()
runCatching {
web.evaluateJavascript(script) { result ->
if (finished) return@evaluateJavascript
val data = runCatching {
require(result.length < 50_000)
JSONObject(result)
}.getOrNull()
when (data?.optString("state")) {
"loading" -> handler.postDelayed(pull, 50)
"ready" -> {
val chunk = runCatching { Base64.decode(data.getString("data"), Base64.NO_WRAP) }.getOrNull()
if (chunk == null || chunk.size > 32768 || offset + chunk.size > maxBytes) {
finish(false)
} else {
offset += chunk.size
runCatching {
worker.execute {
val success = runCatching { requireNotNull(output).write(chunk) }.isSuccess
handler.post {
if (!success) {
finish(false)
} else if (data.optBoolean("done")) {
finish(true)
} else {
handler.post(pull)
}
}
}
}.onFailure { finish(false) }
}
}
else -> finish(false)
}
}
}.onFailure { finish(false) }
}
handler.postDelayed(expiry, 45_000)
runCatching {
worker.execute {
output = runCatching { resolver.openOutputStream(destination, "w") }.getOrNull()
handler.post {
if (finished) {
runCatching { output?.close() }
return@post
}
if (output == null || !allowed()) {
finish(false)
return@post
}
val script = """
(function(){window[$key]={state:'loading'};
try{const b=window[${JSONObject.quote(store)}].take(${JSONObject.quote(value)});
if(!b || b.size>$maxBytes)throw new Error('size');
b.arrayBuffer().then(a=>window[$key]={state:'ready',bytes:new Uint8Array(a)})
.catch(()=>window[$key]={state:'failed'});
}catch(e){window[$key]={state:'failed'};}})()
""".trimIndent()
runCatching { web.evaluateJavascript(script) { handler.post(pull) } }.onFailure { finish(false) }
}
}
}.onFailure { finish(false) }
}
}
+39
View File
@@ -0,0 +1,39 @@
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()
}
}
}
+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()
}
}
+23
View File
@@ -0,0 +1,23 @@
package community.yuvomi.mobile
/** Never delivers a late system-picker result into a different server/web context. */
class SelectionGate {
private var owner: Int? = null
val outstanding: Boolean get() = owner != null
fun begin(generation: Int): Boolean {
if (outstanding) return false
owner = generation
return true
}
fun consume(generation: Int): Boolean {
val previous = owner
owner = null
return previous == generation
}
fun restoreOutstanding() {
owner = -1
}
}
+31
View File
@@ -0,0 +1,31 @@
package community.yuvomi.mobile
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SelectionGateTest {
@Test fun acceptsOnlyTheInitiatingContextOnce() {
val gate = SelectionGate()
assertTrue(gate.begin(0))
assertTrue(gate.consume(0))
assertFalse(gate.consume(0))
}
@Test fun serverSwitchCannotDeliverAnOldSelectionToTheNewServer() {
val gate = SelectionGate()
assertTrue(gate.begin(0))
assertFalse(gate.begin(1))
assertFalse(gate.consume(1))
assertTrue(gate.begin(1))
assertTrue(gate.consume(1))
}
@Test fun processRecreationCancelsSelectionWithoutReusingItsCallback() {
val gate = SelectionGate()
gate.restoreOutstanding()
assertFalse(gate.begin(0))
assertFalse(gate.consume(0))
assertTrue(gate.begin(0))
}
}
+5
View File
@@ -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>
+111
View File
@@ -0,0 +1,111 @@
package community.yuvomi.mobile
import android.content.Context
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.io.File
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BlobExportTest {
@Test fun rejectsBlobExportFromOpaqueNativePageEvenWithClaimedOrigin() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
val file = File(File(context.cacheDir, "captures").apply { mkdirs() }, "blob-test.csv")
val uri = FileProvider.getUriForFile(context, "${context.packageName}.files", file)
val worker = Executors.newSingleThreadExecutor()
try {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var web: WebView
val created = CountDownLatch(1)
var blob = ""
scenario.onActivity {
// A real WebView with a controlled document, not a network mock.
// The HTTPS upstream suite separately exercises the app's client.
web = WebView(it)
web.settings.javaScriptEnabled = true
web.loadDataWithBaseURL(
"https://family.example/",
"<!doctype html><title>Export test</title>",
"text/html",
"UTF-8",
null,
)
}
val deadline = System.nanoTime() + 10_000_000_000L
var ready = false
while (!ready && System.nanoTime() < deadline) {
val checked = CountDownLatch(1)
scenario.onActivity {
web.evaluateJavascript("location.origin") {
ready =
it == "\"https://family.example\""
checked.countDown()
}
}
assertTrue(checked.await(2, TimeUnit.SECONDS))
if (!ready) Thread.sleep(50)
}
assertTrue("Trusted document loaded", ready)
scenario.onActivity {
web.evaluateJavascript(
"URL.createObjectURL(new Blob(['name,value\\nYuvomi,Grüße\\n'],{type:'text/csv'}))",
) {
blob = JSONArray("[$it]").getString(0)
created.countDown()
}
}
assertTrue(created.await(10, TimeUnit.SECONDS))
assertTrue(
"Blob inherits trusted origin: $blob",
BlobTransfer.owns(ServerAddress.parse("https://family.example"), blob),
)
assertNotNull(
context.contentResolver.openOutputStream(uri, "w")?.also {
it.close()
},
)
val saved = CountDownLatch(1)
var success = false
scenario.onActivity {
assertFalse(
"Native page URL remains opaque despite loadDataWithBaseURL",
ServerAddress.parse("https://family.example").owns(web.url.orEmpty()),
)
BlobTransfer.save(
ServerAddress.parse("https://family.example"),
web,
blob,
context.contentResolver,
uri,
worker,
{ true },
) {
success = it
saved.countDown()
}
}
assertTrue(saved.await(50, TimeUnit.SECONDS))
assertFalse(success)
assertEquals(0L, file.length())
scenario.onActivity { web.destroy() }
}
} finally {
worker.shutdownNow()
file.delete()
}
}
}
@@ -0,0 +1,66 @@
package community.yuvomi.mobile
import android.content.Context
import android.net.Uri
import android.view.View
import android.view.ViewGroup
import android.webkit.WebResourceRequest
import android.webkit.WebView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class BrowserSecurityTest {
@Test fun blocksCrossOriginPostAndSubresourceBeforeNetwork() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
lateinit var web: WebView
lateinit var client: android.webkit.WebViewClient
scenario.onActivity {
web = requireNotNull(findWeb(it.window.decorView))
client = web.webViewClient
}
// Interception runs off the UI thread in WebView. POST navigations do
// not reliably call shouldOverrideUrlLoading, so this is a separate gate.
for (mainFrame in listOf(true, false)) {
val response = client.shouldInterceptRequest(
web,
Request("https://evil.example/collect", mainFrame),
)
assertNotNull(response)
assertEquals(403, response!!.statusCode)
}
assertNull(
client.shouldInterceptRequest(web, Request("https://family.example/tasks", true)),
)
}
}
private fun findWeb(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWeb(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
private class Request(private val value: String, private val mainFrame: Boolean) : WebResourceRequest {
override fun getUrl(): Uri = Uri.parse(value)
override fun isForMainFrame() = mainFrame
override fun isRedirect() = false
override fun hasGesture() = true
override fun getMethod() = "POST"
override fun getRequestHeaders(): MutableMap<String, String> = mutableMapOf()
}
}
@@ -0,0 +1,71 @@
package community.yuvomi.mobile
import android.Manifest
import android.accessibilityservice.AccessibilityServiceInfo
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.view.accessibility.AccessibilityNodeInfo
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class LocalNetworkPermissionTest {
@Test fun permissionIsOptionalAndHonorsSystemDenialAndGrant() {
val context = ApplicationProvider.getApplicationContext<Context>()
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use {
if (Build.VERSION.SDK_INT < 37 ||
context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED
) {
onView(withText("Allow private-network servers")).check(doesNotExist())
return@use
}
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
automation.serviceInfo = automation.serviceInfo.apply { flags = flags or AccessibilityServiceInfo.FLAG_REPORT_VIEW_IDS }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Cancel")).perform(click())
assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK))
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_deny_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_DENIED }
onView(withText("Allow private-network servers")).perform(click())
onView(withText("Continue")).perform(click())
chooseSystemButton("permission_allow_button")
await { context.checkSelfPermission(Manifest.permission.ACCESS_LOCAL_NETWORK) == PackageManager.PERMISSION_GRANTED }
}
}
private fun chooseSystemButton(id: String) {
val automation = InstrumentationRegistry.getInstrumentation().uiAutomation
await {
val node = find(automation.rootInActiveWindow, id) ?: return@await false
node.performAction(AccessibilityNodeInfo.ACTION_CLICK)
}
}
private fun find(node: AccessibilityNodeInfo?, id: String): AccessibilityNodeInfo? {
if (node == null) return null
if (node.viewIdResourceName?.endsWith("/$id") == true) return node
for (index in 0 until node.childCount) find(node.getChild(index), id)?.let { return it }
return null
}
private fun await(condition: () -> Boolean) {
val deadline = System.nanoTime() + 10_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
assertTrue("System permission response timed out", condition())
}
}
+93
View File
@@ -0,0 +1,93 @@
package community.yuvomi.mobile
import android.content.Context
import android.webkit.WebView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasErrorText
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isEnabled
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class OnboardingTest {
private val context: Context get() = ApplicationProvider.getApplicationContext()
@Before fun clearConfiguration() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
}
@Test fun rejectsHttpAndSubpathsLocally() {
ActivityScenario.launch(MainActivity::class.java).use {
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("http://family.example"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Use HTTPS with a valid server hostname.")))
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText("https://family.example/subpath"), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
onView(
withContentDescription("Yuvomi server URL"),
).check(matches(hasErrorText("Yuvomi requires a dedicated hostname, not a subpath.")))
}
}
@Test fun survivesRecreationWithoutLosingOnboarding() {
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
scenario.recreate()
onView(withText("Connect securely")).check(matches(isDisplayed()))
onView(withContentDescription("Yuvomi server URL")).check(matches(isEnabled()))
}
}
@Test fun webViewIsHardenedAndBackgroundingPreservesInstance() {
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit()
.putString("origin", "https://family.example").commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
var initial: WebView? = null
scenario.onActivity { activity ->
fun find(view: android.view.View): WebView? {
if (view is WebView) return view
if (view is android.view.ViewGroup) {
for (i in 0 until view.childCount) {
find(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
initial = find(activity.window.decorView)
val web = requireNotNull(initial)
assertFalse(web.settings.allowFileAccess)
assertFalse(web.settings.allowContentAccess)
assertEquals(
android.webkit.WebSettings.MIXED_CONTENT_NEVER_ALLOW,
web.settings.mixedContentMode,
)
assertTrue(web.settings.javaScriptEnabled)
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
scenario.onActivity { assertNotNull(initial?.parent) }
}
}
}
@@ -0,0 +1,333 @@
package community.yuvomi.mobile
import android.content.Context
import android.view.View
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import androidx.core.content.FileProvider
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.action.ViewActions.closeSoftKeyboard
import androidx.test.espresso.action.ViewActions.replaceText
import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withContentDescription
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import java.io.File
import java.net.URI
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import javax.net.ssl.HttpsURLConnection
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UpstreamIntegrationTest {
@Test fun actualUpstreamOnboardingAuthenticationLifecycleAndLogout() {
val arguments = InstrumentationRegistry.getArguments()
val origin = requireNotNull(arguments.getString("server"))
val password = requireNotNull(arguments.getString("password"))
val context = ApplicationProvider.getApplicationContext<Context>()
if (android.os.Build.VERSION.SDK_INT >= 37) {
InstrumentationRegistry.getInstrumentation().uiAutomation.grantRuntimePermission(
context.packageName,
android.Manifest.permission.ACCESS_LOCAL_NETWORK,
)
}
context.getSharedPreferences("server", Context.MODE_PRIVATE).edit().clear().commit()
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
onView(
withContentDescription("Yuvomi server URL"),
).perform(replaceText(origin), closeSoftKeyboard())
onView(withText("Connect securely")).perform(click())
var web: WebView? = null
await("native onboarding completed") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null
}
await("actual upstream application loaded") {
documentLoaded(scenario, requireNotNull(web), origin)
}
// Run in the real WebView origin, preserving real Set-Cookie and CSRF.
val credentials = JSONObject().put(
"username",
"mobiletest",
).put("password", password).toString()
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult = null;
fetch('/api/v1/auth/login', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify($credentials)}).then(async r => {
const data = await r.json(); window.__mobileCsrf=data.csrfToken;
window.__mobileResult=r.status;
});
""".trimIndent(),
)
await("upstream password login") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
assertEquals(
"false",
evaluate(scenario, requireNotNull(web), "document.cookie.includes('yuvomi.sid=')"),
)
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/documents',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:JSON.stringify({name:'Mobile upload',
original_name:'mobile.txt',content_data:'data:text/plain;base64,WXV2b21pIGZpbGUgdGVzdAo='})})
.then(async r=>{const d=await r.json();window.__mobileDocument=d.data?.id;window.__mobileResult=r.status});
""".trimIndent(),
)
await("real upstream file upload") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"201"
}
val documentId = evaluate(
scenario,
requireNotNull(web),
"window.__mobileDocument",
).toLong()
val downloadURL = "$origin/api/v1/documents/$documentId/download"
var cookie = ""
scenario.onActivity { cookie = CookieManager.getInstance().getCookie(downloadURL) }
val file =
File(File(context.cacheDir, "captures").apply { mkdirs() }, "upstream-download.txt")
val destination = FileProvider.getUriForFile(
context,
"${context.packageName}.files",
file,
)
try {
FileTransfer.save(
ServerAddress.parse(origin),
downloadURL,
cookie,
context.contentResolver,
destination,
)
assertEquals("Yuvomi file test\n", file.readText())
} finally {
file.delete()
}
val blobResult =
evaluate(
scenario,
requireNotNull(web),
"URL.createObjectURL(new Blob(['Yuvomi export'],{type:'text/plain'}))",
)
val blobURL = JSONArray("[$blobResult]").getString(0)
val exported = CountDownLatch(1)
val exportWorker = Executors.newSingleThreadExecutor()
var exportSuccess = false
try {
scenario.onActivity {
BlobTransfer.save(
ServerAddress.parse(origin),
requireNotNull(web),
blobURL,
context.contentResolver,
destination,
exportWorker,
{ true },
) {
exportSuccess = it
exported.countDown()
}
}
assertTrue(exported.await(50, TimeUnit.SECONDS))
assertTrue("Blob export in hardened application WebView", exportSuccess)
assertEquals("Yuvomi export", file.readText())
} finally {
exportWorker.shutdownNow()
file.delete()
}
scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED)
scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED)
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session survives backgrounding") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
scenario.recreate()
await("restored WebView") {
scenario.onActivity { web = findWeb(it.window.decorView) }
web != null && documentLoaded(scenario, requireNotNull(web), origin)
}
await("persistent session after activity recreation") {
evaluate(
scenario,
requireNotNull(web),
"""
if(!window.__mobileSessionProbe){window.__mobileSessionProbe=true;
fetch('/api/v1/auth/me').then(async r=>{const d=await r.json();
window.__mobileCsrf=d.csrfToken;window.__mobileResult=r.status})}
window.__mobileResult
""".trimIndent(),
) ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"""
window.__mobileResult=null;
fetch('/api/v1/auth/logout',{method:'POST',headers:{'Content-Type':'application/json',
'X-CSRF-Token':window.__mobileCsrf},body:'{}'}).then(r=>window.__mobileResult=r.status);
""".trimIndent(),
)
await("CSRF-protected logout") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"200"
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileResult=null;fetch('/api/v1/auth/me').then(r=>window.__mobileResult=r.status)",
)
await("session revoked") {
evaluate(scenario, requireNotNull(web), "window.__mobileResult") ==
"401"
}
// The upstream worker may legitimately serve the cached app shell
// during an outage. Use non-cacheable metadata to exercise native
// main-frame network recovery without disabling that worker.
evaluate(
scenario,
requireNotNull(web),
"fetch('/__mobile_test/network/off',{method:'POST'}).then(()=>location.assign('/api/v1/version'))",
)
await("native network error recovery panel") {
var visible = false
scenario.onActivity { visible = hasText(it.window.decorView, "Connection interrupted") }
visible
}
val recovery = URI("$origin/__mobile_test/network/on").toURL().openConnection() as HttpsURLConnection
try {
recovery.requestMethod = "POST"
recovery.connectTimeout = 5_000
recovery.readTimeout = 5_000
assertEquals(200, recovery.responseCode)
} finally {
recovery.disconnect()
}
onView(withText("Retry")).perform(click())
await("reconnected to real upstream metadata") {
evaluate(scenario, requireNotNull(web), "document.body?.innerText.includes('app_name')") == "true"
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/") }
await("application restored after outage") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == "$origin/" }
restored && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { requireNotNull(web).loadUrl("$origin/api/v1/auth/oidc/start") }
await("native consent before leaving Yuvomi for OIDC") {
runCatching { onView(withText("Sign-in window")).check(matches(isDisplayed())) }.isSuccess
}
onView(withText("Sign-in window")).perform(click())
await("OIDC callback returns to main application") {
var returned = false
scenario.onActivity { returned = requireNotNull(web).url == "$origin/" }
val signInClosed = runCatching {
onView(withText("Close")).check(doesNotExist())
}.isSuccess
signInClosed && returned &&
documentLoaded(scenario, requireNotNull(web), origin)
}
evaluate(
scenario,
requireNotNull(web),
"window.__mobileSubject=null;fetch('/api/v1/auth/me').then(r=>r.json()).then(d=>window.__mobileSubject=d.user?.username)",
)
await("isolated OIDC view shares the initiating server session") {
evaluate(scenario, requireNotNull(web), "window.__mobileSubject") == "\"mobile-oidc\""
}
var previous = ""
scenario.onActivity {
previous = requireNotNull(web).url.orEmpty()
requireNotNull(web).loadUrl("$origin/tasks")
}
await("internal module navigation") {
var loaded = false
scenario.onActivity { loaded = requireNotNull(web).url == "$origin/tasks" && requireNotNull(web).canGoBack() }
loaded && documentLoaded(scenario, requireNotNull(web), origin)
}
scenario.onActivity { it.onBackPressedDispatcher.onBackPressed() }
await("native back restores WebView history") {
var restored = false
scenario.onActivity { restored = requireNotNull(web).url == previous }
restored
}
}
}
private fun documentLoaded(scenario: ActivityScenario<MainActivity>, web: WebView, origin: String): Boolean = evaluate(
scenario,
web,
"location.origin===${JSONObject.quote(origin)} && document.readyState==='complete'",
) == "true"
private fun evaluate(scenario: ActivityScenario<MainActivity>, web: WebView, javascript: String): String {
val completed = CountDownLatch(1)
var result = ""
scenario.onActivity {
web.evaluateJavascript(javascript) {
result = it
completed.countDown()
}
}
assertTrue("JavaScript evaluation timed out", completed.await(10, TimeUnit.SECONDS))
return result
}
private fun await(label: String, condition: () -> Boolean) {
val deadline = System.nanoTime() + 30_000_000_000L
while (System.nanoTime() < deadline) {
if (condition()) return
Thread.sleep(100)
}
fail("Timed out: $label")
}
private fun findWeb(view: View): WebView? {
if (view is WebView) return view
if (view is ViewGroup) {
for (i in 0 until view.childCount) {
findWeb(view.getChildAt(i))?.let {
return it
}
}
}
return null
}
private fun hasText(view: View, text: String): Boolean {
if (view is android.widget.TextView && view.text.toString() == text) return true
if (view is ViewGroup) {
for (index in 0 until view.childCount) {
if (hasText(view.getChildAt(index), text)) return true
}
}
return false
}
}