diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts index 2786e3a2..5cbbe440 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 = 25 - versionName = "0.5.5" + versionCode = 26 + versionName = "0.5.6" 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 039e0be2..01bc313e 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 @@ -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(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(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(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(null) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var favicon by remember { mutableStateOf(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 } diff --git a/neode-ui/public/packages/archipelago-companion.apk b/neode-ui/public/packages/archipelago-companion.apk index 10d295ec..6233da49 100644 Binary files a/neode-ui/public/packages/archipelago-companion.apk and b/neode-ui/public/packages/archipelago-companion.apk differ