Merge remote-tracking branch 'gitea-ai/main'

This commit is contained in:
archipelago 2026-07-23 19:13:20 -04:00
commit b28e5f2ef6
3 changed files with 69 additions and 8 deletions

View File

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

View File

@ -87,6 +87,28 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject 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). */ /** Open a URL in the phone's default browser (genuinely external links). */
private fun openExternalUrl(context: android.content.Context, url: String) { private fun openExternalUrl(context: android.content.Context, url: String) {
try { try {
@ -168,6 +190,20 @@ fun WebViewScreen(
var hasError by remember { mutableStateOf(false) } var hasError by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) } 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 // Web-page camera access (wallet QR scanner). The WebView's default
// WebChromeClient silently denies getUserMedia, so grant video capture — // WebChromeClient silently denies getUserMedia, so grant video capture —
// asking for the app-level CAMERA permission first when needed. // 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. // while this is shown, so closing it returns instantly with no reload.
var inAppUrl by remember { mutableStateOf<String?>(null) } 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 // Native wallet QR scanner, opened by the web UI via the ArchipelagoQr
// bridge; status lines stream back from the page while it's up. // bridge; status lines stream back from the page while it's up.
var walletScannerVisible by remember { mutableStateOf(false) } var walletScannerVisible by remember { mutableStateOf(false) }
@ -268,9 +312,13 @@ fun WebViewScreen(
GlassButton( GlassButton(
text = stringResource(R.string.retry), text = stringResource(R.string.retry),
onClick = { onClick = {
// Re-race LAN vs mesh — the network we're on may have
// changed since the last pick.
hasError = false hasError = false
isLoading = true isLoading = true
webView?.loadUrl(serverUrl) triedMeshFallback = false
startUrl = null
raceNonce++
}, },
modifier = Modifier.fillMaxWidth().height(56.dp), modifier = Modifier.fillMaxWidth().height(56.dp),
) )
@ -283,9 +331,16 @@ fun WebViewScreen(
modifier = Modifier.fillMaxWidth().height(48.dp), 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 { } else {
// Edge-to-edge WebView — background bleeds behind status bar. // Edge-to-edge WebView — background bleeds behind status bar.
// Safe area values injected as CSS env() polyfill on each page load. // Safe area values injected as CSS env() polyfill on each page load.
val initialUrl = startUrl ?: serverUrl
AndroidView( AndroidView(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { context -> factory = { context ->
@ -319,7 +374,7 @@ fun WebViewScreen(
// kiosk couldn't iframe — keep the user inside the app) // kiosk couldn't iframe — keep the user inside the app)
// - different host → the phone's real browser // - different host → the phone's real browser
fun routeOutbound(url: String) { fun routeOutbound(url: String) {
if (isSameHost(url, serverUrl)) { if (isSameNode(url)) {
inAppUrl = url inAppUrl = url
} else { } else {
openExternalUrl(context, url) openExternalUrl(context, url)
@ -458,7 +513,7 @@ fun WebViewScreen(
error: android.net.http.SslError?, error: android.net.http.SslError?,
) { ) {
val u = error?.url val u = error?.url
if (u != null && isSameHost(u, serverUrl)) { if (u != null && isSameNode(u)) {
handler?.proceed() handler?.proceed()
} else { } else {
handler?.cancel() handler?.cancel()
@ -595,7 +650,7 @@ fun WebViewScreen(
} }
webView = this webView = this
loadUrl(serverUrl) loadUrl(initialUrl)
} }
}, },
) )
@ -620,6 +675,7 @@ fun WebViewScreen(
InAppBrowser( InAppBrowser(
url = target, url = target,
serverUrl = serverUrl, serverUrl = serverUrl,
meshUrl = meshFallbackUrl,
onClose = { inAppUrl = null }, onClose = { inAppUrl = null },
) )
} }
@ -693,9 +749,14 @@ private fun fetchFavicon(pageUrl: String): Bitmap? {
private fun InAppBrowser( private fun InAppBrowser(
url: String, url: String,
serverUrl: String, serverUrl: String,
meshUrl: String? = null,
onClose: () -> Unit, onClose: () -> Unit,
) { ) {
val context = LocalContext.current 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 browser by remember { mutableStateOf<WebView?>(null) }
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
var favicon by remember { mutableStateOf<Bitmap?>(null) } var favicon by remember { mutableStateOf<Bitmap?>(null) }
@ -809,7 +870,7 @@ private fun InAppBrowser(
error: android.net.http.SslError?, error: android.net.http.SslError?,
) { ) {
val u = error?.url val u = error?.url
if (u != null && isSameHost(u, serverUrl)) { if (u != null && isSameNode(u)) {
handler?.proceed() handler?.proceed()
} else { } else {
handler?.cancel() handler?.cancel()
@ -823,7 +884,7 @@ private fun InAppBrowser(
val u = request?.url?.toString() ?: return false val u = request?.url?.toString() ?: return false
// Stay in the overlay for same-node navigation; // Stay in the overlay for same-node navigation;
// hand genuinely external links to the real browser. // hand genuinely external links to the real browser.
if (isSameHost(u, serverUrl)) return false if (isSameNode(u)) return false
openExternalUrl(ctx, u) openExternalUrl(ctx, u)
return true return true
} }