perf(companion): cold-start and interaction overhaul from the launch audit

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-08-02 13:09:15 +01:00
co-authored by Claude Fable 5
parent f893a9e804
commit 372ff7aa65
9 changed files with 315 additions and 96 deletions
@@ -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
}
}
}
@@ -19,7 +19,13 @@ class MainActivity : ComponentActivity() {
private val pendingPairUri = MutableStateFlow<String?>(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 },
)
}
}
@@ -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<Preferences> 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<ServerEntry?> = 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<ServerEntry?> = context.dataStore.data
.map { prefs -> activeServerFrom(prefs) }
.distinctUntilChanged()
val savedServers: Flow<List<ServerEntry>> = 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<Boolean> = context.dataStore.data.map { prefs ->
prefs[introSeenKey] ?: false
}
}.distinctUntilChanged()
/** One-shot flag for the three-finger-hold teaching overlay. */
val gestureHintSeen: Flow<Boolean> = 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<LaunchState> = context.dataStore.data.map { prefs ->
LaunchState(
introSeen = prefs[introSeenKey] ?: false,
activeServer = activeServerFrom(prefs),
)
}.distinctUntilChanged()
suspend fun setActiveServer(server: ServerEntry) {
context.dataStore.edit { prefs ->
@@ -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.
@@ -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<android.graphics.Bitmap?>(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 {
@@ -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))
@@ -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<android.graphics.Bitmap?>(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<android.graphics.Bitmap?>(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 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)
val uri = androidx.core.content.FileProvider.getUriForFile(
}
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)
@@ -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<Boolean> {
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<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = 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<javax.net.ssl.TrustManager>(object : X509TrustManager {
override fun checkClientTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun checkServerTrusted(chain: Array<java.security.cert.X509Certificate>?, authType: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = 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 }
}
@@ -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<String, String>()
private const val STORE = "start_url_cache"
private val lastGood = java.util.concurrent.ConcurrentHashMap<String, String>()
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<Pair<String?, kotlinx.coroutines.Deferred<String?>>> {
lan.onAwait { it to mesh }
mesh.onAwait { it to lan }
var pending = racers.toMutableList()
var result: String? = null
while (result == null && pending.isNotEmpty()) {
val (value, done) = kotlinx.coroutines.selects.select<Pair<String?, kotlinx.coroutines.Deferred<String?>>> {
pending.forEach { d -> d.onAwait { it to d } }
}
if (first != null) {
other.cancel()
first
} else {
other.await()
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<String?>(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<String, Bitmap>(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
}