From 372ff7aa65b6482bfd343e82c4a603573d194f05 Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 28 Jul 2026 09:58:10 +0100 Subject: [PATCH] perf(companion): cold-start and interaction overhaul from the launch audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Splash held until launch state resolves; introSeen+activeServer now one combined DataStore emission (no black frame, no Connect-screen flash). - Application warmups: fips core dlopen, DataStore first-read, Chromium provider load — all off the first-paint window. - Start-origin cache persisted across process death; cached/LAN/mesh probes race three-wide (cold relaunch starts in milliseconds). - ServerConnect: tunnel warms at screen entry; LAN/mesh race in parallel (was 5s dead LAN wait, then tunnel start, then 15s probes); SSLContext built once, not per attempt. - Party: QR encode off-main + debounced (was per keystroke), share-APK copy off-main + cached; Flare: sampled off-main photo decode, derived message filter; Intro: graphicsLayer alpha, parallel fade. - Kiosk: safe-area JS injection deduped per insets value, inset observer disconnects once the page settles, BackHandler off live Chromium calls, loader icons LRU-cached. Co-Authored-By: Claude Fable 5 --- .../com/archipelago/app/ArchipelagoApp.kt | 37 ++++- .../java/com/archipelago/app/MainActivity.kt | 9 +- .../archipelago/app/data/ServerPreferences.kt | 39 +++++- .../archipelago/app/ui/navigation/NavGraph.kt | 18 ++- .../archipelago/app/ui/screens/FlareScreen.kt | 27 +++- .../archipelago/app/ui/screens/IntroScreen.kt | 10 +- .../archipelago/app/ui/screens/PartyScreen.kt | 55 ++++++-- .../app/ui/screens/ServerConnectScreen.kt | 85 ++++++++---- .../app/ui/screens/WebViewScreen.kt | 131 +++++++++++++----- 9 files changed, 315 insertions(+), 96 deletions(-) diff --git a/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt index ed001ab1..32f15edb 100644 --- a/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt +++ b/Android/app/src/main/java/com/archipelago/app/ArchipelagoApp.kt @@ -1,5 +1,40 @@ package com.archipelago.app import android.app.Application +import android.os.Looper +import android.webkit.WebView +import com.archipelago.app.data.ServerPreferences +import com.archipelago.app.fips.FipsNative +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch -class ArchipelagoApp : Application() +class ArchipelagoApp : Application() { + + private val warmupScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + override fun onCreate() { + super.onCreate() + + // Warmups that otherwise land inside the first frame: + // - FipsNative.available dlopens the 7 MB Rust core; referenced from + // composition (NESMenu, mesh auto-start), it blocked the UI thread. + // - The first DataStore read gates the nav graph's start destination; + // parsing it here means the launch gate resolves in the first + // emission instead of waiting on cold disk IO. + warmupScope.launch { + FipsNative.available + runCatching { ServerPreferences(this@ArchipelagoApp).launchState.first() } + } + + // First WebView construction pays Chromium provider load (~150-400 ms + // cold). Absorb it while the main thread is idle before the kiosk + // needs it, instead of serially after the connection probe. + Looper.getMainLooper().queue.addIdleHandler { + runCatching { WebView(this).destroy() } + false // one-shot + } + } +} diff --git a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt index 3e066864..953dce7f 100644 --- a/Android/app/src/main/java/com/archipelago/app/MainActivity.kt +++ b/Android/app/src/main/java/com/archipelago/app/MainActivity.kt @@ -19,7 +19,13 @@ class MainActivity : ComponentActivity() { private val pendingPairUri = MutableStateFlow(null) override fun onCreate(savedInstanceState: Bundle?) { - installSplashScreen() + // Hold the branded system splash until the nav graph has its launch + // state — without this the splash dropped at the first composed frame, + // which was EMPTY (the DataStore read hadn't landed): splash → black + // flash → UI on every launch. + var navReady = false + val splash = installSplashScreen() + splash.setKeepOnScreenCondition { !navReady } enableEdgeToEdge() super.onCreate(savedInstanceState) pendingPairUri.value = intent?.dataString @@ -29,6 +35,7 @@ class MainActivity : ComponentActivity() { AppNavHost( pairUri = pairUri, onPairUriConsumed = { pendingPairUri.value = null }, + onReady = { navReady = true }, ) } } diff --git a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt index 6d0458aa..da9e3a27 100644 --- a/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt +++ b/Android/app/src/main/java/com/archipelago/app/data/ServerPreferences.kt @@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringSetPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map private val Context.dataStore: DataStore by preferencesDataStore(name = "server_prefs") @@ -89,9 +90,9 @@ class ServerPreferences(private val context: Context) { private val introSeenKey = booleanPreferencesKey("intro_seen") private val gestureHintSeenKey = booleanPreferencesKey("gesture_hint_seen") - val activeServer: Flow = context.dataStore.data.map { prefs -> - val address = prefs[activeAddressKey] ?: return@map null - ServerEntry( + private fun activeServerFrom(prefs: Preferences): ServerEntry? { + val address = prefs[activeAddressKey] ?: return null + return ServerEntry( address = address, useHttps = prefs[activeHttpsKey] ?: false, port = prefs[activePortKey] ?: "", @@ -102,19 +103,43 @@ class ServerPreferences(private val context: Context) { ) } + // distinctUntilChanged on every flow: DataStore emits on EVERY write to the + // file regardless of key, and each spurious emission recomposed whatever + // screen collected it (the kiosk recomposed on gesture-hint writes). + + val activeServer: Flow = context.dataStore.data + .map { prefs -> activeServerFrom(prefs) } + .distinctUntilChanged() + val savedServers: Flow> = context.dataStore.data.map { prefs -> val raw = prefs[savedServersKey] ?: emptySet() - raw.mapNotNull { ServerEntry.deserialize(it) } - } + // Sorted so set-iteration order can't produce a structurally different + // list for the same servers (which defeats distinctUntilChanged). + raw.mapNotNull { ServerEntry.deserialize(it) }.sortedBy { it.displayName() } + }.distinctUntilChanged() val introSeen: Flow = context.dataStore.data.map { prefs -> prefs[introSeenKey] ?: false - } + }.distinctUntilChanged() /** One-shot flag for the three-finger-hold teaching overlay. */ val gestureHintSeen: Flow = context.dataStore.data.map { prefs -> prefs[gestureHintSeenKey] ?: false - } + }.distinctUntilChanged() + + /** Everything the nav graph needs to pick a start destination, derived + * from ONE DataStore emission. Collecting introSeen and activeServer as + * two separate flows let them land in different frames — the intro flag + * could resolve first and flash the Connect screen at a paired user + * before the active server arrived. */ + data class LaunchState(val introSeen: Boolean, val activeServer: ServerEntry?) + + val launchState: Flow = context.dataStore.data.map { prefs -> + LaunchState( + introSeen = prefs[introSeenKey] ?: false, + activeServer = activeServerFrom(prefs), + ) + }.distinctUntilChanged() suspend fun setActiveServer(server: ServerEntry) { context.dataStore.edit { prefs -> diff --git a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt index 138c817b..ef17ed1b 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/navigation/NavGraph.kt @@ -28,7 +28,9 @@ import com.archipelago.app.ui.screens.PartyScreen import com.archipelago.app.ui.screens.RemoteInputScreen import com.archipelago.app.ui.screens.ServerConnectScreen import com.archipelago.app.ui.screens.WebViewScreen +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext object Routes { const val INTRO = "intro" @@ -43,14 +45,18 @@ object Routes { fun AppNavHost( pairUri: String? = null, onPairUriConsumed: () -> Unit = {}, + onReady: () -> Unit = {}, ) { val context = LocalContext.current val prefs = remember { ServerPreferences(context) } val navController = rememberNavController() val scope = rememberCoroutineScope() - val introSeen by prefs.introSeen.collectAsState(initial = null) - val activeServer by prefs.activeServer.collectAsState(initial = null) + // One combined emission — introSeen and activeServer resolving in separate + // frames used to flash the Connect screen at paired users on launch. + val launchState by prefs.launchState.collectAsState(initial = null) + val introSeen = launchState?.introSeen + val activeServer = launchState?.activeServer // Pairing entry from a deep link that carried no password — prefills the // connect form so the user lands on the password prompt for that server. @@ -80,11 +86,17 @@ fun AppNavHost( } // Paired + previously consented → the mesh comes back silently on launch. + // Off the main dispatcher: this path dlopens the 7 MB fips core and does a + // binder round-trip (VpnService.prepare) — it was landing inside the first + // frame's effect batch. LaunchedEffect(Unit) { - FipsManager.autoStartIfReady(context) + withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) } } if (introSeen == null) return + // Launch state resolved — MainActivity holds the system splash until now, + // so the first visible frame is the real UI, never a black gap. + LaunchedEffect(Unit) { onReady() } // Declared after the introSeen gate so it can't fire before the NavHost // below has set the nav graph; pairUri stays pending until consumed here. diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt index f9f24a30..486dd8dc 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/FlareScreen.kt @@ -107,7 +107,11 @@ fun FlareScreen(onBack: () -> Unit) { } val peer = peers.firstOrNull { it.npub == selectedNpub } - val messages = allMessages.filter { it.peerNpub == selectedNpub } + // derivedStateOf: filtering inline re-ran over the whole store on every + // recomposition — including one per keystroke in the composer. + val messages by remember(selectedNpub) { + androidx.compose.runtime.derivedStateOf { allMessages.filter { it.peerNpub == selectedNpub } } + } val listState = rememberLazyListState() LaunchedEffect(messages.size) { if (messages.isNotEmpty()) listState.animateScrollToItem(messages.size - 1) @@ -305,7 +309,13 @@ private fun MessageBubble(msg: FlareMessage) { .padding(horizontal = 12.dp, vertical = 8.dp), ) { if (msg.photoPath.isNotBlank()) { - val bmp = remember(msg.photoPath) { BitmapFactory.decodeFile(msg.photoPath) } + // Decoded off-main and downsampled to the bubble width — + // full-size decode in remember{} ran on the UI thread mid- + // scroll and held ~8 MB per visible photo (OOM territory). + var bmp by remember(msg.photoPath) { mutableStateOf(null) } + LaunchedEffect(msg.photoPath) { + bmp = withContext(Dispatchers.IO) { decodeSampledPhoto(msg.photoPath, 600) } + } bmp?.let { Image( bitmap = it.asImageBitmap(), @@ -336,6 +346,19 @@ private fun MessageBubble(msg: FlareMessage) { } /** Decode, downscale (≤1600px) and JPEG-compress a picked photo off-main. */ +/** Decode a stored beamed photo at roughly [maxPx] on the long edge — the + * bubble renders at ~300 dp, so the stored 1600 px original is 25× the + * pixels needed. Blocking — call on IO. */ +private fun decodeSampledPhoto(path: String, maxPx: Int): android.graphics.Bitmap? = try { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(path, bounds) + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / (sample * 2) >= maxPx) sample *= 2 + BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample }) +} catch (_: Exception) { + null +} + private suspend fun compressPhoto(context: android.content.Context, uri: Uri): ByteArray? = withContext(Dispatchers.IO) { try { diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt index db42dff9..a428cc79 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/IntroScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size @@ -65,9 +66,10 @@ fun IntroScreen( var showContent by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - logoAlpha.animateTo(1f, animationSpec = tween(800)) - delay(300) + // Content fades in WITH the logo, not after it — the serial + // 800ms + 300ms sequence held "Get Started" off-screen for 1.1s. showContent = true + logoAlpha.animateTo(1f, animationSpec = tween(450)) } Box( @@ -111,7 +113,9 @@ fun IntroScreen( contentDescription = "Archipelago", modifier = Modifier .size(160.dp) - .alpha(logoAlpha.value), + // graphicsLayer defers the alpha read to the draw phase — + // .alpha(value) recomposed the whole screen per frame. + .graphicsLayer { alpha = logoAlpha.value }, ) Spacer(modifier = Modifier.height(48.dp)) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt index 31494993..e1ab1032 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/PartyScreen.kt @@ -123,9 +123,12 @@ fun PartyScreen( name = prefs.partyName() // The hotspot/WiFi address can change while this screen is open // (e.g. the user flips the hotspot on mid-demo) — keep it fresh. + // Tight only at first (the hotspot-flip window); interface walks + // allocate, so back off once the screen has been open a while. + var round = 0 while (true) { localIp = withContext(Dispatchers.IO) { PartyQr.localWifiIpv4() } - delay(3_000) + delay(if (round++ < 10) 3_000 else 30_000) } } @@ -138,7 +141,16 @@ fun PartyScreen( port = PartyQr.PARTY_UDP_PORT, ) } - val qrBitmap = remember(qrPayload) { qrPayload?.let { renderQr(it) } } + // QR encode + bitmap fill off the composition: done in remember{} it ran + // on the UI thread PER KEYSTROKE of the name field (the payload embeds the + // name) — a ZXing encode plus a megabyte-plus allocation per character. + // The 250 ms delay is a free debounce via coroutine cancellation. + var qrBitmap by remember { mutableStateOf(null) } + LaunchedEffect(qrPayload) { + if (qrPayload == null) { qrBitmap = null; return@LaunchedEffect } + if (qrBitmap != null) delay(250) + qrBitmap = withContext(Dispatchers.Default) { renderQr(qrPayload) } + } BackHandler { when { @@ -337,7 +349,12 @@ fun PartyScreen( contentAlignment = Alignment.Center, ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - val dlQr = remember { renderQr(APP_DOWNLOAD_URL) } + // Encoded off-main; done in remember{} it dropped the + // overlay's first fade-in frame. + var dlQr by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + dlQr = withContext(Dispatchers.Default) { renderQr(APP_DOWNLOAD_URL) } + } dlQr?.let { bmp -> Box( Modifier @@ -364,7 +381,7 @@ fun PartyScreen( "…or send the APK file directly", color = BitcoinOrange, fontSize = 13.sp, - modifier = Modifier.clickable { shareCompanionApk(context) }.padding(8.dp), + modifier = Modifier.clickable { scope.launch { shareCompanionApk(context) } }.padding(8.dp), ) Spacer(Modifier.height(6.dp)) Text("Close", color = TextMuted, fontSize = 14.sp, modifier = Modifier.clickable { showShareQr = false }.padding(8.dp)) @@ -473,8 +490,9 @@ fun PartyScreen( } } -/** Render a QR payload as a bitmap (dark modules on white). */ -private fun renderQr(payload: String, size: Int = 640): Bitmap? = try { +/** Render a QR payload as a bitmap (dark modules on white). 512 px covers the + * 240.dp display size at any density; 640 was a third more pixels for nothing. */ +private fun renderQr(payload: String, size: Int = 512): Bitmap? = try { val matrix = QRCodeWriter().encode( payload, BarcodeFormat.QR_CODE, @@ -494,16 +512,23 @@ private fun renderQr(payload: String, size: Int = 640): Bitmap? = try { } /** Share this install's own APK via the system share sheet — a nearby friend - * gets the companion with no internet at all (Quick Share / Bluetooth). */ -private fun shareCompanionApk(context: android.content.Context) { + * gets the companion with no internet at all (Quick Share / Bluetooth). + * The ~27 MB copy runs on IO — inline in the click handler it froze the UI + * for seconds (ANR territory on slow flash). Copied once per install; the + * cached file is reused while its size still matches the source. */ +private suspend fun shareCompanionApk(context: android.content.Context) { try { - val src = java.io.File(context.applicationInfo.sourceDir) - val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } - val out = java.io.File(dir, "archipelago-companion.apk") - src.copyTo(out, overwrite = true) - val uri = androidx.core.content.FileProvider.getUriForFile( - context, "${context.packageName}.fileprovider", out, - ) + val uri = withContext(Dispatchers.IO) { + val src = java.io.File(context.applicationInfo.sourceDir) + val dir = java.io.File(context.cacheDir, "share").apply { mkdirs() } + val out = java.io.File(dir, "archipelago-companion.apk") + if (!out.exists() || out.length() != src.length()) { + src.copyTo(out, overwrite = true) + } + androidx.core.content.FileProvider.getUriForFile( + context, "${context.packageName}.fileprovider", out, + ) + } val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { type = "application/vnd.android.package-archive" putExtra(android.content.Intent.EXTRA_STREAM, uri) diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt index 166f527f..0830ae92 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/ServerConnectScreen.kt @@ -86,6 +86,7 @@ import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary import com.archipelago.app.ui.theme.TextSecondary import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -108,6 +109,13 @@ fun ServerConnectScreen( val scope = rememberCoroutineScope() val keyboard = LocalSoftwareKeyboardController.current + // Warm the mesh tunnel the moment the screen appears — starting it only + // after the LAN probe failed put full tunnel bring-up + session discovery + // inside the user's wait. By connect-tap time it's usually already up. + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { FipsManager.autoStartIfReady(context) } + } + var name by remember { mutableStateOf("") } var address by remember { mutableStateOf("") } var port by remember { mutableStateOf("") } @@ -173,30 +181,43 @@ fun ServerConnectScreen( errorMessage = null scope.launch { - var reachable = testConnection(server) - - // LAN address didn't answer — phone off-LAN (5G) or DHCP moved the - // node. The scanned IP was only ever a dial hint; the node's real + // LAN and mesh race IN PARALLEL — the serial LAN-then-mesh chain + // burned a guaranteed-dead 5 s LAN probe before the mesh path even + // started (the off-LAN QR-pairing case, exactly where speed shows). + // The scanned IP was only ever a dial hint; the node's real // identity is its npub and its ULA is reachable from anywhere over - // the mesh. Bring the tunnel up and probe the ULA before failing. - if (!reachable && server.meshIp.isNotBlank()) { + // the mesh. Mesh discovery + first session can take 15s+ through + // the public tree (HANDOFF-2026-07-23 node diagnosis), and on a + // first-ever pairing the VPN consent dialog is on screen at the + // same time — so the mesh side keeps probing inside its budget + // while the tunnel (already started at screen entry, and kicked + // again here) warms up underneath. + val meshServer = server.meshIp.takeIf { it.isNotBlank() }?.let { FipsManager.autoStartIfReady(context) - val meshServer = server.copy( - address = server.meshIp, - useHttps = false, - port = "", - ) - // Mesh discovery + first session can take 15s+ through the - // public tree (HANDOFF-2026-07-23 node diagnosis), and on a - // first-ever pairing the VPN consent dialog is on screen at - // the same time — so probe patiently inside a 60s budget with - // per-attempt timeouts wide enough to ride out TCP - // retransmit backoff. The VPN service pre-warms the session - // in parallel (ArchyVpnService.startSessionWarmer). - val deadline = System.currentTimeMillis() + 60_000 - while (!reachable && System.currentTimeMillis() < deadline) { - reachable = testConnection(meshServer, timeoutMs = 15_000) - if (!reachable) delay(3000) + server.copy(address = it, useHttps = false, port = "") + } + val reachable = kotlinx.coroutines.coroutineScope { + val lan = async { testConnection(server, timeoutMs = 4_000) } + val mesh = async { + if (meshServer == null) return@async false + val deadline = System.currentTimeMillis() + 45_000 + var ok = false + while (!ok && System.currentTimeMillis() < deadline) { + ok = testConnection(meshServer, timeoutMs = 8_000) + if (!ok) delay(2000) + } + ok + } + val first = kotlinx.coroutines.selects.select { + lan.onAwait { it } + mesh.onAwait { it } + } + if (first) { + lan.cancel(); mesh.cancel() + true + } else { + // One side gave up — the verdict is whatever the other says. + if (lan.isCompleted) mesh.await() else lan.await() } } isConnecting = false @@ -686,6 +707,17 @@ private fun sanitizeAddress(input: String): String { .trimEnd('/') } +// Built once — the connect loop probed up to 20 times, and each attempt was +// paying a fresh SSLContext + SecureRandom init. +private val trustAllSslFactory: javax.net.ssl.SSLSocketFactory by lazy { + val trustAll = arrayOf(object : X509TrustManager { + override fun checkClientTrusted(chain: Array?, authType: String?) {} + override fun checkServerTrusted(chain: Array?, authType: String?) {} + override fun getAcceptedIssuers(): Array = arrayOf() + }) + SSLContext.getInstance("TLS").apply { init(null, trustAll, java.security.SecureRandom()) }.socketFactory +} + /** Test RPC connectivity. Accepts self-signed certs for local LAN servers. * [timeoutMs] is per-phase (connect / read) — mesh probes need far more * patience than LAN ones (first session through the tree can take 15s+). */ @@ -697,14 +729,7 @@ private suspend fun testConnection(server: ServerEntry, timeoutMs: Int = 5000): // Trust self-signed certs for local HTTPS (Archipelago nodes rarely have CA certs) if (connection is HttpsURLConnection) { - val trustAll = arrayOf(object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) {} - override fun checkServerTrusted(chain: Array?, authType: String?) {} - override fun getAcceptedIssuers(): Array = arrayOf() - }) - val sc = SSLContext.getInstance("TLS") - sc.init(null, trustAll, java.security.SecureRandom()) - connection.sslSocketFactory = sc.socketFactory + connection.sslSocketFactory = trustAllSslFactory connection.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true } } diff --git a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt index 4bae9925..a5ca350d 100644 --- a/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt +++ b/Android/app/src/main/java/com/archipelago/app/ui/screens/WebViewScreen.kt @@ -185,6 +185,11 @@ private fun injectSafeAreaVars(view: WebView) { val density = view.resources.displayMetrics.density val sat = (insets.getInsets(android.view.WindowInsets.Type.statusBars()).top / density).toInt() val sab = (insets.getInsets(android.view.WindowInsets.Type.navigationBars()).bottom / density).toInt() + // The insets listener fires on every pass (every IME show/hide); skip the + // JS round-trip — and the Vue event it dispatches — when nothing changed. + val stamp = "sa:$sat,$sab" + if (view.tag == stamp) return + view.tag = stamp view.evaluateJavascript( """ (function() { @@ -301,19 +306,30 @@ private fun injectTopInset(view: WebView) { apply(); sweep(); if (!window.__archyInsetObserver) { - var queued = false, last = 0; - window.__archyInsetObserver = new MutationObserver(function() { + var queued = false, last = 0, idleSweeps = 0; + var obs = new MutationObserver(function() { if (queued) return; queued = true; var wait = Math.max(0, 250 - (Date.now() - last)); setTimeout(function() { queued = false; last = Date.now(); + var before = document.querySelectorAll('[data-archy-inset]').length; apply(); sweep(); + var after = document.querySelectorAll('[data-archy-inset]').length; + // Page has settled — stop paying the computed-style + // tax on every SPA mutation. onPageStarted/Finished + // re-run the injection, which reinstalls us. + idleSweeps = (after === before) ? idleSweeps + 1 : 0; + if (idleSweeps >= 12) { + obs.disconnect(); + window.__archyInsetObserver = null; + } }, wait); }); - window.__archyInsetObserver.observe(document.documentElement, + window.__archyInsetObserver = obs; + obs.observe(document.documentElement, { childList: true, subtree: true }); } })(); @@ -336,44 +352,67 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { /** Last origin that actually answered, per LAN|mesh pair — so a relaunch * starts loading in milliseconds instead of re-running discovery probes. - * Wrong guesses (network changed) are caught by a fast revalidation probe. */ + * Persisted: the in-memory map alone only survived the process, which made + * exactly the launch that mattered (cold start next morning) the slow one. */ private object StartUrlCache { - val lastGood = java.util.concurrent.ConcurrentHashMap() + private const val STORE = "start_url_cache" + private val lastGood = java.util.concurrent.ConcurrentHashMap() + + fun get(context: android.content.Context, key: String): String? = + lastGood[key] ?: context.getSharedPreferences(STORE, 0).getString(key, null) + ?.also { lastGood[key] = it } + + fun put(context: android.content.Context, key: String, origin: String) { + lastGood[key] = origin + context.getSharedPreferences(STORE, 0).edit().putString(key, origin).apply() + } + + fun invalidate(context: android.content.Context, key: String) { + lastGood.remove(key) + context.getSharedPreferences(STORE, 0).edit().remove(key).apply() + } + + fun key(lanUrl: String, meshUrl: String?) = "$lanUrl|${meshUrl ?: ""}" } -/** Fastest answering origin, decided the fast way: - * 1. The origin that worked last time, revalidated with a short probe — - * the warm path costs one TCP connect (~10 ms on LAN or a warm mesh - * session), which is why launches should NEVER feel slow twice. - * 2. Otherwise LAN and mesh probes race IN PARALLEL (they were serial: - * 2.5 s of dead LAN wait before the mesh probe even started — the - * "lightning sometimes, garbage other times" launch variance). - * If neither answers, fall back to the mesh URL when we have one — off-LAN +/** Fastest answering origin. THREE probes race in parallel — the persisted + * last-good origin (short timeout: it usually answers in ~10-50 ms on LAN or + * a warm mesh session), the LAN URL, and the mesh ULA (patient — a cold + * session may still be establishing). Racing the cached probe instead of + * checking it first means a stale cache entry costs nothing: the other + * racers are already running. Serial probing was the "lightning sometimes, + * garbage other times" launch variance — 2.5 s of dead LAN wait before the + * mesh probe even started. + * If nothing answers, fall back to the mesh URL when we have one — off-LAN * the LAN IP is unreachable, and loading it just produced a confusing * "can't reach 192.168.x.x" error page (user-reported 2026-07-27). */ -private suspend fun pickStartUrl(lanUrl: String, meshUrl: String?): String = +private suspend fun pickStartUrl( + context: android.content.Context, + lanUrl: String, + meshUrl: String?, +): String = withContext(Dispatchers.IO) { - val key = "$lanUrl|${meshUrl ?: ""}" - StartUrlCache.lastGood[key]?.let { cached -> - if (tcpAnswers(cached, 1500)) return@withContext cached - } + val key = StartUrlCache.key(lanUrl, meshUrl) + val cached = StartUrlCache.get(context, key) val winner = kotlinx.coroutines.coroutineScope { - val lan = async { if (tcpAnswers(lanUrl, 2500)) lanUrl else null } - val mesh = async { - if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) meshUrl else null + val racers = buildList { + cached?.let { c -> add(async { if (tcpAnswers(c, 1200)) c else null }) } + add(async { if (tcpAnswers(lanUrl, 2500)) lanUrl else null }) + meshUrl?.let { m -> add(async { if (tcpAnswers(m, 8_000)) m else null }) } } - val (first, other) = kotlinx.coroutines.selects.select>> { - lan.onAwait { it to mesh } - mesh.onAwait { it to lan } - } - if (first != null) { - other.cancel() - first - } else { - other.await() + var pending = racers.toMutableList() + var result: String? = null + while (result == null && pending.isNotEmpty()) { + val (value, done) = kotlinx.coroutines.selects.select>> { + pending.forEach { d -> d.onAwait { it to d } } + } + pending.remove(done) + result = value } + racers.forEach { it.cancel() } + result } - winner?.also { StartUrlCache.lastGood[key] = it } ?: meshUrl ?: lanUrl + winner?.also { StartUrlCache.put(context, key, it) } ?: meshUrl ?: lanUrl } /** Apply the WebView settings shared by the kiosk view and the in-app browser. @@ -447,6 +486,8 @@ fun WebViewScreen( // ~2.5s off-LAN, so startup lands on the right origin in seconds. var startUrl by remember(serverUrl) { mutableStateOf(null) } var raceNonce by remember { mutableIntStateOf(0) } + var kioskCanGoBack by remember { mutableStateOf(false) } + val appContext = LocalContext.current.applicationContext LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) { // A retained live session exists — reattach instantly: no race, no // reload, no re-login (remote ⇄ dashboard round trip). @@ -455,7 +496,7 @@ fun WebViewScreen( startUrl = serverUrl return@LaunchedEffect } - val picked = pickStartUrl(serverUrl, meshFallbackUrl) + val picked = pickStartUrl(appContext, serverUrl, meshFallbackUrl) // Starting on the mesh: don't bounce back to it on error (it IS it). if (picked != serverUrl) triedMeshFallback = true startUrl = picked @@ -529,7 +570,10 @@ fun WebViewScreen( pendingFileChooser = null } - BackHandler(enabled = inAppLaunch == null && webView?.canGoBack() == true) { + // canGoBack() is a live Chromium call, not snapshot state — read from + // composition it was stale (navigation doesn't recompose). Tracked via + // doUpdateVisitedHistory instead, like the in-app overlay already does. + BackHandler(enabled = inAppLaunch == null && kioskCanGoBack) { webView?.goBack() } @@ -578,8 +622,13 @@ fun WebViewScreen( text = stringResource(R.string.retry), onClick = { // Re-race LAN vs mesh — the network we're on may have - // changed since the last pick. Drop the retained view: + // changed since the last pick, so the remembered + // origin is suspect too. Drop the retained view: // an errored session must genuinely reload. + StartUrlCache.invalidate( + webViewContext, + StartUrlCache.key(serverUrl, meshFallbackUrl), + ) KioskWebView.drop() webView = null hasError = false @@ -749,10 +798,18 @@ fun WebViewScreen( override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { isLoading = true hasError = false + // New document — the injected safe-area style is + // gone; reset the dedup stamp so it re-injects. + view?.tag = null + } + + override fun doUpdateVisitedHistory(view: WebView?, url: String?, isReload: Boolean) { + kioskCanGoBack = view?.canGoBack() == true } override fun onPageFinished(view: WebView?, url: String?) { isLoading = false + kioskCanGoBack = view?.canGoBack() == true if (view == null) return injectSafeAreaVars(view) @@ -1116,9 +1173,14 @@ fun WebViewScreen( } } +/** Catalog icons re-fetched over the mesh on every app open made the loader + * show the fallback logo first — cache the handful of icons a node has. */ +private val iconCache = androidx.collection.LruCache(24) + /** Best-effort fetch of an image URL (the app's catalog icon) for the branded * loading screen. Blocking — call on IO. */ private fun fetchBitmap(imageUrl: String): Bitmap? { + iconCache.get(imageUrl)?.let { return it } return try { val conn = (java.net.URL(imageUrl).openConnection() as java.net.HttpURLConnection).apply { @@ -1127,6 +1189,7 @@ private fun fetchBitmap(imageUrl: String): Bitmap? { instanceFollowRedirects = true } conn.inputStream.use { BitmapFactory.decodeStream(it) } + ?.also { iconCache.put(imageUrl, it) } } catch (_: Exception) { null }