fix(companion): status-bar safe-area contract, branded loader+error screens, parallel launch probes — 0.5.26

- 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 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-08-02 13:07:39 +01:00
co-authored by Claude Fable 5
parent 5718179e2f
commit 4ea3fb2deb
2 changed files with 273 additions and 76 deletions
+2 -2
View File
@@ -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
@@ -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<String, String>()
}
/** 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<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()
}
}
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<String?>(null) }
var inAppLaunch by remember { mutableStateOf<InAppLaunch?>(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 <head> 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 <title> (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))