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:
@@ -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 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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
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<Pair<String?, kotlinx.coroutines.Deferred<String?>>> {
|
||||
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<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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user