From 4ea3fb2deb6a49397cfa95c836bcf572f751cbed Mon Sep 17 00:00:00 2001 From: Dorian Date: Mon, 27 Jul 2026 21:51:09 +0100 Subject: [PATCH] =?UTF-8?q?fix(companion):=20status-bar=20safe-area=20cont?= =?UTF-8?q?ract,=20branded=20loader+error=20screens,=20parallel=20launch?= =?UTF-8?q?=20probes=20=E2=80=94=200.5.26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Safe-area v3: backgrounds extend under the status bar, content never does. Body padding + effective top background sampling (fixes white strip on light-bodied apps), fixed/sticky headers pushed below the bar, full-height overlays padded instead of shifted (no see-through strip). - Loader: pulsating app-icon card (catalog icon via openInAppEx bridge, pixel-logo fallback) — site favicons removed. - Branded CloudOff error screen with Retry; Chromium's stock error page can no longer be seen in app tabs. - pickStartUrl: LAN/mesh probes race in parallel and the last-good origin is cached — kills the 2.5s serial dead-LAN wait behind launch variance. Co-Authored-By: Claude Fable 5 --- Android/app/build.gradle.kts | 4 +- .../app/ui/screens/WebViewScreen.kt | 345 ++++++++++++++---- 2 files changed, 273 insertions(+), 76 deletions(-) diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 0fb58dd7..b3d0756a 100644 --- a/Android/app/build.gradle.kts +++ b/Android/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "com.archipelago.app" minSdk = 26 targetSdk = 35 - versionCode = 45 - versionName = "0.5.25" + versionCode = 46 + versionName = "0.5.26" vectorDrawables { useSupportLibrary = 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 dbf90741..4bae9925 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 @@ -19,8 +19,15 @@ import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.border import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -73,6 +80,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -100,6 +108,7 @@ import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -137,6 +146,10 @@ private fun isSameHost(url: String, base: String): Boolean { /** Kiosk WebView retained across navigation (remote ⇄ dashboard) so leaving * the kiosk and coming back reattaches the LIVE page — no reload, no * re-login, no reconnect. Dropped on retry/disconnect/server change. */ +/** What the in-app overlay opens: the URL plus (when the web UI provides + * them) the app's catalog icon and display name for the branded loader. */ +data class InAppLaunch(val url: String, val icon: String? = null, val name: String? = null) + private object KioskWebView { var instance: WebView? = null var url: String? = null @@ -145,7 +158,7 @@ private object KioskWebView { // registered interface objects call through these, so reattaching the // retained view re-points them instead of leaving stale closures. var onRouteOutbound: (String) -> Unit = {} - var onOpenInApp: (String) -> Unit = {} + var onOpenInApp: (InAppLaunch) -> Unit = {} var onQrOpen: () -> Unit = {} var onQrStatus: (String, Boolean) -> Unit = { _, _ -> } var onQrClose: () -> Unit = {} @@ -191,19 +204,28 @@ private fun injectSafeAreaVars(view: WebView) { ) } -/** In-app browser pages (node apps + same-node links) don't consume the - * neode-ui `--safe-area-top` var, so with the WebView drawing edge-to-edge - * their content ran up under the status bar. Pad the document body down by - * the status-bar height: the padded strip shows the page's OWN background - * (padding is inside the element), so the bar keeps the page colour while - * content starts below it — the pre-edge-to-edge look, without the black bar. +/** Status-bar treatment for in-app pages, the contract being: the page's + * BACKGROUND (colour or imagery) extends up into the status-bar area, while + * page CONTENT always starts below it. The WebView draws edge-to-edge; this + * injection makes any page honour that contract: * - * Body padding only moves normal-flow content. fixed/sticky elements anchored - * at the viewport top (IndeeHub's floating header) stayed glued under the - * status bar, so we also push each of those down by the inset — once, marked - * via data attribute — and keep a throttled MutationObserver running so - * headers an SPA mounts after load get the same treatment. - * Idempotent; runs on start (early) and finish (after the app rewrites head). */ + * - `body{padding-top}` moves normal-flow content down; body background + * (colour + background-image) keeps painting across the padded strip. + * - The strip must show the page's EFFECTIVE background, not body's declared + * one — LND declares a white body under a dark full-height root, which + * painted a white bar. We sample the rendered background at the top of the + * viewport (elementFromPoint, walking up to the first opaque colour) and + * repaint body with it, only when body's own background is transparent or + * disagrees with what's actually rendered. + * - fixed/sticky bars anchored at the top (IndeeHub's floating header) don't + * move with body padding — each is pushed down by the inset, once. + * - full-height fixed overlays (modals) must NOT move down (that exposed the + * page behind them in a strip) — they get padding-top instead, so their own + * background covers the bar. + * - a throttled MutationObserver re-applies the fixed/sticky treatment to + * elements the SPA mounts after load. + * Idempotent; runs on start (early, so content never flashes under the bar) + * and finish (after the app rewrites head; layout exists for sampling). */ private fun injectTopInset(view: WebView) { val insets = view.rootWindowInsets ?: return val density = view.resources.displayMetrics.density @@ -213,31 +235,70 @@ private fun injectTopInset(view: WebView) { """ (function() { var SAT = $sat; - var s = document.getElementById('archy-top-inset'); - if (!s) { - s = document.createElement('style'); - s.id = 'archy-top-inset'; - (document.head || document.documentElement).appendChild(s); + var CLEAR = 'rgba(0, 0, 0, 0)'; + function styleEl() { + var s = document.getElementById('archy-top-inset'); + if (!s) { + s = document.createElement('style'); + s.id = 'archy-top-inset'; + (document.head || document.documentElement).appendChild(s); + } + return s; + } + // Effective rendered background at the top of the viewport: from + // the topmost element at a few probe points, walk up to the first + // ancestor that actually paints a colour. + function effectiveTopBg() { + if (!document.body || !document.elementFromPoint) return ''; + var w = window.innerWidth; + var probes = [[w >> 1, SAT + 2], [8, SAT + 2], [w - 8, SAT + 2]]; + for (var i = 0; i < probes.length; i++) { + var el = document.elementFromPoint(probes[i][0], probes[i][1]); + while (el && el !== document.documentElement) { + var c = getComputedStyle(el).backgroundColor; + if (c && c !== CLEAR && c !== 'transparent') return c; + el = el.parentElement; + } + } + return ''; + } + function apply() { + var bodyBg = document.body + ? getComputedStyle(document.body).backgroundColor : ''; + var eff = effectiveTopBg(); + // Repaint body only when its declared background is not what + // the page actually renders at the top (or paints nothing). + var fix = eff && eff !== bodyBg + ? 'background-color:' + eff + ' !important;' : ''; + styleEl().textContent = + 'body{padding-top:' + SAT + 'px !important;' + + 'box-sizing:border-box !important;' + fix + '}'; } - s.textContent = - 'body{padding-top:' + SAT + 'px!important;box-sizing:border-box!important;}'; function push(el) { if (el.dataset.archyInset) return; var cs = getComputedStyle(el); if (cs.position !== 'fixed' && cs.position !== 'sticky') return; var top = parseFloat(cs.top); // 'auto' -> NaN skips bottom bars if (isNaN(top) || top >= SAT) return; - el.style.setProperty('top', (top + SAT) + 'px', 'important'); + // offsetHeight ignores enter-animation transforms. + if (el.offsetHeight >= window.innerHeight - SAT - 1) { + var pt = parseFloat(cs.paddingTop) || 0; + el.style.setProperty('padding-top', (pt + SAT) + 'px', 'important'); + el.style.setProperty('box-sizing', 'border-box', 'important'); + } else { + el.style.setProperty('top', (top + SAT) + 'px', 'important'); + } el.dataset.archyInset = '1'; } function sweep() { if (!document.body) return; - // Fixed/sticky bars live shallow in the tree (portals mount on - // body); depth cap keeps the computed-style pass off big lists. + // Fixed/sticky bars live shallow (portals mount on body); the + // depth cap keeps the computed-style pass off big lists. var els = document.body.querySelectorAll( 'body > *, body > * > *, body > * > * > *, body > * > * > * > *'); for (var i = 0; i < els.length; i++) push(els[i]); } + apply(); sweep(); if (!window.__archyInsetObserver) { var queued = false, last = 0; @@ -248,6 +309,7 @@ private fun injectTopInset(view: WebView) { setTimeout(function() { queued = false; last = Date.now(); + apply(); sweep(); }, wait); }); @@ -272,18 +334,46 @@ private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try { false } -/** Fastest answering origin: LAN inside a short window, else the mesh ULA - * (patient — a cold session may still be establishing). If NEITHER 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). Targeting the mesh URL - * instead means the load retries against the path that's actually coming up, - * and any error shows the mesh address rather than a dead LAN IP. */ +/** 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. */ +private object StartUrlCache { + val lastGood = java.util.concurrent.ConcurrentHashMap() +} + +/** 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 + * 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 = withContext(Dispatchers.IO) { - if (tcpAnswers(lanUrl, 2500)) return@withContext lanUrl - if (meshUrl != null && tcpAnswers(meshUrl, 12_000)) return@withContext meshUrl - meshUrl ?: lanUrl + val key = "$lanUrl|${meshUrl ?: ""}" + StartUrlCache.lastGood[key]?.let { cached -> + if (tcpAnswers(cached, 1500)) return@withContext cached + } + 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 (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() + } + } + winner?.also { StartUrlCache.lastGood[key] = it } ?: meshUrl ?: lanUrl } /** Apply the WebView settings shared by the kiosk view and the in-app browser. @@ -388,7 +478,7 @@ fun WebViewScreen( // A node app that refused iframing, opened in a local WebView overlay. // null = no overlay. The kiosk WebView underneath stays alive (and warm) // while this is shown, so closing it returns instantly with no reload. - var inAppUrl by remember { mutableStateOf(null) } + var inAppLaunch by remember { mutableStateOf(null) } // Same node = EITHER of its addresses. Over the mesh the kiosk's host is // the ULA while app links may carry the LAN IP (and vice versa) — @@ -439,7 +529,7 @@ fun WebViewScreen( pendingFileChooser = null } - BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { + BackHandler(enabled = inAppLaunch == null && webView?.canGoBack() == true) { webView?.goBack() } @@ -570,7 +660,7 @@ fun WebViewScreen( // - different host → the phone's real browser fun routeOutbound(url: String) { if (isSameNode(url)) { - inAppUrl = url + inAppLaunch = InAppLaunch(url) } else { openExternalUrl(context, url) } @@ -586,7 +676,7 @@ fun WebViewScreen( // launch"). These assignments re-point the live // interface objects at THIS composition every attach. KioskWebView.onRouteOutbound = { url -> routeOutbound(url) } - KioskWebView.onOpenInApp = { url -> inAppUrl = url } + KioskWebView.onOpenInApp = { launch -> inAppLaunch = launch } KioskWebView.onQrOpen = { walletScannerStatus = null walletScannerVisible = true @@ -607,7 +697,23 @@ fun WebViewScreen( @android.webkit.JavascriptInterface fun openInApp(url: String) { - webViewRef.post { KioskWebView.onOpenInApp(url) } + webViewRef.post { KioskWebView.onOpenInApp(InAppLaunch(url)) } + } + + // Richer launch: the web UI passes the app's + // catalog icon + display name for the branded + // pulsating loader (never a site favicon). + @android.webkit.JavascriptInterface + fun openInAppEx(url: String, iconUrl: String, name: String) { + webViewRef.post { + KioskWebView.onOpenInApp( + InAppLaunch( + url, + iconUrl.takeUnless { it.isBlank() }, + name.takeUnless { it.isBlank() }, + ), + ) + } } }, "ArchipelagoNative", @@ -902,12 +1008,14 @@ fun WebViewScreen( // In-app browser overlay for non-iframeable node apps. Rendered last // so it sits above the kiosk WebView, which stays alive underneath. - inAppUrl?.let { target -> + inAppLaunch?.let { target -> InAppBrowser( - url = target, + url = target.url, serverUrl = serverUrl, meshUrl = meshFallbackUrl, - onClose = { inAppUrl = null }, + appIcon = target.icon, + appName = target.name, + onClose = { inAppLaunch = null }, ) } @@ -933,7 +1041,7 @@ fun WebViewScreen( // First-launch teaching overlay for the three-finger hold — armed // ~2 minutes after login so it never fights the splash/first look. if (gestureHintReady && !gestureHintSeen && !gestureHintDismissed && - !isLoading && inAppUrl == null + !isLoading && inAppLaunch == null ) { GestureHintOverlay( onDismiss = { @@ -1008,16 +1116,11 @@ fun WebViewScreen( } } -/** Best-effort fetch of the origin's /favicon.ico, so the launched app's icon - * can be shown on the loading screen before the WebView reports onReceivedIcon - * (which only fires once the page's has parsed). Blocking — call on IO. */ -private fun fetchFavicon(pageUrl: String): Bitmap? { +/** 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? { return try { - val u = android.net.Uri.parse(pageUrl) - val scheme = u.scheme ?: return null - val host = u.host ?: return null - val portPart = if (u.port > 0) ":${u.port}" else "" - val conn = (java.net.URL("$scheme://$host$portPart/favicon.ico").openConnection() + val conn = (java.net.URL(imageUrl).openConnection() as java.net.HttpURLConnection).apply { connectTimeout = 4000 readTimeout = 4000 @@ -1043,6 +1146,8 @@ private fun InAppBrowser( url: String, serverUrl: String, meshUrl: String? = null, + appIcon: String? = null, + appName: String? = null, onClose: () -> Unit, ) { val context = LocalContext.current @@ -1057,16 +1162,23 @@ private fun InAppBrowser( // real (onReceivedTitle upgrades it). var title by remember { mutableStateOf( - android.net.Uri.parse(url).host - ?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) } + appName + ?: android.net.Uri.parse(url).host + ?.takeUnless { it.contains(':') || it.matches(Regex("^\\d+(\\.\\d+){3}$")) } ?: "Archipelago", ) } - var favicon by remember { mutableStateOf<Bitmap?>(null) } + // Loader icon: the app's CATALOG icon passed by the web UI — never the + // site favicon (generic favicons made the loader look broken). Falls back + // to the Archipelago pixel logo while absent. + var loaderIcon by remember { mutableStateOf<Bitmap?>(null) } var progress by remember { mutableIntStateOf(0) } var loading by remember { mutableStateOf(true) } var canGoBack by remember { mutableStateOf(false) } var canGoForward by remember { mutableStateOf(false) } + // Main-frame load failure — the branded offline screen renders instead of + // Chromium's stock "Webpage not available" page (never show that). + var loadError by remember { mutableStateOf(false) } // Same camera bridge as the main WebView — node apps opened in the overlay // (e.g. anything with a QR scanner) get getUserMedia too. @@ -1080,13 +1192,12 @@ private fun InAppBrowser( pendingWebPermission = null } - // Seed the loading-screen icon immediately from a best-effort favicon - // pre-fetch (main's app-icon work), then onReceivedIcon upgrades it — so the - // loader shows an icon right away instead of staying blank until the page - // parses its <head> (which is what made the loader look stuck). - LaunchedEffect(url) { - val fetched = withContext(Dispatchers.IO) { fetchFavicon(url) } - if (fetched != null && favicon == null) favicon = fetched + // Fetch the app's catalog icon for the loader (absolute URL from the web + // UI). No favicon fallback — the pixel logo is the brand fallback. + LaunchedEffect(appIcon) { + if (appIcon != null) { + loaderIcon = withContext(Dispatchers.IO) { fetchBitmap(appIcon) } + } } // Back: walk the in-app history first, then close the overlay. @@ -1109,13 +1220,13 @@ private fun InAppBrowser( onClick = {}, ) // Bottom inset handled by the touch-shield strip below the bar. - // No TOP inset padding: the WebView draws edge-to-edge behind the - // status bar so the app's own background fills it — the padded - // version painted an opaque black bar there (user-rejected look). .windowInsetsPadding( WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal) ), ) { + // No native top strip: the WebView draws edge-to-edge so page + // BACKGROUNDS extend into the status-bar area; injectTopInset keeps + // page CONTENT below it (the design contract for every in-app page). // WebView + loading overlay fill the area above the bottom control bar. Box(modifier = Modifier.weight(1f).fillMaxWidth()) { AndroidView( @@ -1141,10 +1252,6 @@ private fun InAppBrowser( if (!t.isNullOrBlank()) title = t } - override fun onReceivedIcon(view: WebView?, icon: Bitmap?) { - if (icon != null) favicon = icon - } - override fun onPermissionRequest(request: PermissionRequest) { if (PermissionRequest.RESOURCE_VIDEO_CAPTURE !in request.resources) { request.deny() @@ -1165,6 +1272,7 @@ private fun InAppBrowser( webViewClient = object : WebViewClient() { override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { loading = true + loadError = false view?.let { injectTopInset(it) } } @@ -1175,6 +1283,19 @@ private fun InAppBrowser( view?.let { injectTopInset(it) } } + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError?, + ) { + // Sub-resource failures are the page's problem; + // only a dead MAIN FRAME gets the offline screen. + if (request?.isForMainFrame == true) { + loadError = true + loading = false + } + } + override fun doUpdateVisitedHistory(view: WebView?, u: String?, isReload: Boolean) { canGoBack = view?.canGoBack() == true canGoForward = view?.canGoForward() == true @@ -1217,7 +1338,49 @@ private fun InAppBrowser( ) // Centered loading screen — app favicon (or spinner) + title + bar. - if (loading) { + if (loadError) { + // Branded offline screen — Chromium's stock error page is + // rendering underneath, and must never be visible. + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Default.CloudOff, + contentDescription = null, + modifier = Modifier.size(56.dp), + tint = TextMuted, + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = title, + style = MaterialTheme.typography.headlineSmall, + color = TextPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(10.dp)) + Text( + text = stringResource(R.string.connection_failed), + style = MaterialTheme.typography.bodyMedium, + color = TextMuted, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(28.dp)) + GlassButton( + text = stringResource(R.string.retry), + onClick = { + loadError = false + loading = true + browser?.reload() + }, + modifier = Modifier.fillMaxWidth().height(52.dp), + ) + } + } else if (loading) { Column( modifier = Modifier .fillMaxSize() @@ -1225,19 +1388,53 @@ private fun InAppBrowser( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { + // Pulsating app-icon card — mirrors the web UI's + // AppLoadingScreen (84dp, r20, 1.8s scale/fade pulse). + val pulse = rememberInfiniteTransition(label = "loaderPulse") + val scale by pulse.animateFloat( + initialValue = 1f, + targetValue = 1.05f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loaderScale", + ) + val fade by pulse.animateFloat( + initialValue = 1f, + targetValue = 0.85f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "loaderFade", + ) Box( - modifier = Modifier.size(84.dp).clip(RoundedCornerShape(20.dp)), + modifier = Modifier + .size(84.dp) + .graphicsLayer { + scaleX = scale + scaleY = scale + alpha = fade + } + .clip(RoundedCornerShape(20.dp)) + .background(Color.White.copy(alpha = 0.05f)) + .border( + 1.dp, + Color.White.copy(alpha = 0.08f), + RoundedCornerShape(20.dp), + ), contentAlignment = Alignment.Center, ) { - val fav = favicon - if (fav != null) { + val icon = loaderIcon + if (icon != null) { Image( - bitmap = fav.asImageBitmap(), + bitmap = icon.asImageBitmap(), contentDescription = title, modifier = Modifier.fillMaxSize(), ) } else { - CircularProgressIndicator(color = BitcoinOrange) + PixelArtLogo(Modifier.size(48.dp)) } } Spacer(modifier = Modifier.height(18.dp))