Merge remote-tracking branch 'gitea-ai/main'
This commit is contained in:
commit
b28e5f2ef6
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user