feat(companion): instant off-LAN startup + same-node apps stay in-app

Two field reports from tonight's 5G session:

- 'Stuck connecting': the kiosk WebView always loaded the LAN URL first,
  and Chromium burns a minute+ of connect retries on a dead LAN IP before
  the mesh fallback fires. WebViewScreen now races a raw TCP probe — LAN
  gets 2.5s, else the mesh ULA (patient, 12s) — BEFORE creating the
  WebView, so startup lands on the right origin in seconds either side.
  Retry re-races instead of blindly reloading the LAN URL.
- Pine/Home Assistant/BTCPay opened in the external browser over the
  mesh: same-node detection compared one host, but the node has two (LAN
  origin + mesh ULA) — kiosk loaded via the ULA meant app links failed
  isSameHost and routeOutbound bounced them to the browser. Same-node now
  matches either address, in the kiosk router, SSL allowances and the
  InAppBrowser.

Served APK: 0.5.6 (vc26). Note: the binary also carries the in-flight
listen_port groundwork from the dev-box session's archy-fips-core WIP
(compiles clean, default posture unchanged); its source lands separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian 2026-07-24 00:11:33 +01:00
parent 6c48de20e3
commit 5ba3c161c4
3 changed files with 69 additions and 8 deletions

View File

@ -11,8 +11,8 @@ android {
applicationId = "com.archipelago.app"
minSdk = 26
targetSdk = 35
versionCode = 25
versionName = "0.5.5"
versionCode = 26
versionName = "0.5.6"
vectorDrawables {
useSupportLibrary = true

View File

@ -87,6 +87,28 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
/** True when a TCP listener answers at [base]'s host:port within [timeoutMs]. */
private fun tcpAnswers(base: String, timeoutMs: Int): Boolean = try {
val u = android.net.Uri.parse(base)
val port = if (u.port != -1) u.port else if (u.scheme == "https") 443 else 80
java.net.Socket().use {
it.connect(java.net.InetSocketAddress(u.host, port), timeoutMs)
true
}
} catch (_: Exception) {
false
}
/** Fastest answering origin: LAN inside a short window, else the mesh ULA
* (patient a cold session may still be establishing), else LAN anyway so
* the existing error/fallback path handles it. */
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
lanUrl
}
/** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) {
try {
@ -168,6 +190,20 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
// Race LAN vs mesh BEFORE the WebView exists: Chromium pointed at an
// unreachable LAN IP burns a minute+ in connect retries before
// onReceivedError fires the mesh fallback — the "stuck connecting"
// stall. A raw TCP probe answers in milliseconds at home and fails in
// ~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) }
LaunchedEffect(serverUrl, meshFallbackUrl, raceNonce) {
val picked = pickStartUrl(serverUrl, meshFallbackUrl)
// Starting on the mesh: don't bounce back to it on error (it IS it).
if (picked != serverUrl) triedMeshFallback = true
startUrl = picked
}
// Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed.
@ -187,6 +223,14 @@ fun WebViewScreen(
// while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(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) —
// comparing against one host bounced same-node apps (Pine, Home
// Assistant) out to the phone's external browser.
fun isSameNode(url: String): Boolean =
isSameHost(url, serverUrl) ||
(meshFallbackUrl != null && isSameHost(url, meshFallbackUrl))
// Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) }
@ -268,9 +312,13 @@ fun WebViewScreen(
GlassButton(
text = stringResource(R.string.retry),
onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick.
hasError = false
isLoading = true
webView?.loadUrl(serverUrl)
triedMeshFallback = false
startUrl = null
raceNonce++
},
modifier = Modifier.fillMaxWidth().height(56.dp),
)
@ -283,9 +331,16 @@ fun WebViewScreen(
modifier = Modifier.fillMaxWidth().height(48.dp),
)
}
} else if (startUrl == null) {
// Racing LAN vs mesh (≤2.5s at home, a few seconds off-LAN) —
// far cheaper than letting Chromium retry a dead LAN IP.
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = BitcoinOrange)
}
} else {
// Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load.
val initialUrl = startUrl ?: serverUrl
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { context ->
@ -319,7 +374,7 @@ fun WebViewScreen(
// kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser
fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) {
if (isSameNode(url)) {
inAppUrl = url
} else {
openExternalUrl(context, url)
@ -458,7 +513,7 @@ fun WebViewScreen(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@ -595,7 +650,7 @@ fun WebViewScreen(
}
webView = this
loadUrl(serverUrl)
loadUrl(initialUrl)
}
},
)
@ -620,6 +675,7 @@ fun WebViewScreen(
InAppBrowser(
url = target,
serverUrl = serverUrl,
meshUrl = meshFallbackUrl,
onClose = { inAppUrl = null },
)
}
@ -693,9 +749,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
private fun InAppBrowser(
url: String,
serverUrl: String,
meshUrl: String? = null,
onClose: () -> Unit,
) {
val context = LocalContext.current
// Same-node check across BOTH node addresses (LAN + mesh ULA) — see the
// kiosk's isSameNode; a mismatch here bounced app links to the browser.
fun isSameNode(u: String): Boolean =
isSameHost(u, serverUrl) || (meshUrl != null && isSameHost(u, meshUrl))
var browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var favicon by remember { mutableStateOf<Bitmap?>(null) }
@ -809,7 +870,7 @@ private fun InAppBrowser(
error: android.net.http.SslError?,
) {
val u = error?.url
if (u != null && isSameHost(u, serverUrl)) {
if (u != null && isSameNode(u)) {
handler?.proceed()
} else {
handler?.cancel()
@ -823,7 +884,7 @@ private fun InAppBrowser(
val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false
if (isSameNode(u)) return false
openExternalUrl(ctx, u)
return true
}