From b5a9deb8156fc8aae2a171b621bca74f4fca84e0 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 19 Jun 2026 11:28:48 +0100 Subject: [PATCH] feat(android): open non-iframeable apps in in-app webview + webview perf The kiosk's "Open in new tab" used window.open(..., 'noopener,noreferrer'), which the WebView suppresses, so launching apps that can't be iframed did nothing. Route such node apps (same host) into a local in-app WebView overlay instead, keeping the kiosk view alive underneath; genuinely external links still go to the system browser. Wired through onCreateWindow, shouldOverrideUrlLoading, and a new ArchipelagoNative.openInApp() bridge. Perf (no visual change): enable setOffscreenPreRaster to stop scroll checkerboarding, and enable WebView remote debugging on debuggable builds for chrome://inspect profiling. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/ui/screens/WebViewScreen.kt | 279 +++++++++++++++--- Android/app/src/main/res/values/strings.xml | 2 + 2 files changed, 241 insertions(+), 40 deletions(-) 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 ad8f8d41..1ef01c9b 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 @@ -18,6 +18,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -28,8 +29,11 @@ import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.CloudOff +import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -41,8 +45,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.archipelago.app.R @@ -51,7 +57,67 @@ import com.archipelago.app.ui.theme.SurfaceBlack import com.archipelago.app.ui.theme.TextMuted import com.archipelago.app.ui.theme.TextPrimary +/** Open a URL in the phone's default browser (genuinely external links). */ +private fun openExternalUrl(context: android.content.Context, url: String) { + try { + val intent = android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url), + ).apply { + // Required when launching from a non-Activity/binder thread + // (the JS bridge below can run off the UI thread). + addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: Exception) {} +} + +/** True when [url] points at the same host as the connected Archipelago node + * (ignoring port). Such URLs are node apps — e.g. one that can't be iframed — + * and should stay inside the app rather than bouncing out to the browser. */ +private fun isSameHost(url: String, base: String): Boolean { + return try { + val a = android.net.Uri.parse(url).host ?: return false + val b = android.net.Uri.parse(base).host ?: return false + a.equals(b, ignoreCase = true) + } catch (_: Exception) { + false + } +} + +/** Apply the WebView settings shared by the kiosk view and the in-app browser. + * These are tuned for SPA performance and parity with the mobile browser; + * none of them alter how a page renders visually. */ @SuppressLint("SetJavaScriptEnabled") +private fun WebView.applyArchipelagoSettings() { + // Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't + // show blank checkerboarding — the single biggest scroll-smoothness win and + // a major part of the "feels slower than the browser" gap. (API 23+) + settings.setOffscreenPreRaster(true) + + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + databaseEnabled = true + mediaPlaybackRequiresUserGesture = false + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + useWideViewPort = true + loadWithOverviewMode = true + setSupportZoom(false) + builtInZoomControls = false + cacheMode = WebSettings.LOAD_DEFAULT + allowContentAccess = true + allowFileAccess = false + } + + // chrome://inspect profiling on debuggable builds only — lets us measure the + // real in-page bottleneck rather than guess. No effect on release builds. + val debuggable = 0 != (context.applicationInfo.flags and + android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) + if (debuggable) WebView.setWebContentsDebuggingEnabled(true) +} + +@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility") @Composable fun WebViewScreen( serverUrl: String, @@ -63,7 +129,12 @@ fun WebViewScreen( var hasError by remember { mutableStateOf(false) } var webView by remember { mutableStateOf(null) } - BackHandler(enabled = webView?.canGoBack() == true) { + // 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(null) } + + BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) { webView?.goBack() } @@ -132,20 +203,6 @@ fun WebViewScreen( AndroidView( modifier = Modifier.fillMaxSize(), factory = { context -> - fun openExternalUrl(url: String) { - try { - val intent = android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(url), - ).apply { - // Required when launching from a non-Activity/binder - // thread (the JS bridge below runs off the UI thread). - addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) - } - context.startActivity(intent) - } catch (_: Exception) {} - } - WebView(context).apply { layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, @@ -159,19 +216,8 @@ fun WebViewScreen( cookieManager.setAcceptCookie(true) cookieManager.setAcceptThirdPartyCookies(this, true) + applyArchipelagoSettings() settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - databaseEnabled = true - mediaPlaybackRequiresUserGesture = false - mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - useWideViewPort = true - loadWithOverviewMode = true - setSupportZoom(false) - builtInZoomControls = false - cacheMode = WebSettings.LOAD_DEFAULT - allowContentAccess = true - allowFileAccess = false setSupportMultipleWindows(true) // enables onCreateWindow for window.open // Let JS open windows without a synchronous user-gesture // chain; without this, window.open() from a Vue click @@ -179,18 +225,35 @@ fun WebViewScreen( javaScriptCanOpenWindowsAutomatically = true } - // Deterministic bridge for "open in the phone's browser". - // The web UI calls window.ArchipelagoNative.openExternal(url) - // when present (companion app), falling back to window.open - // in a plain mobile browser. This avoids relying on the - // window.open → onCreateWindow path, which noopener/noreferrer - // can suppress in the WebView. val webViewRef = this + + // Decide where an outbound URL goes: + // - same host as the node → in-app WebView overlay + // (this is the "open in browser" target for apps the + // 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)) { + inAppUrl = url + } else { + openExternalUrl(context, url) + } + } + + // JS bridge. The web UI calls: + // window.ArchipelagoNative.openExternal(url) — host-routed + // window.ArchipelagoNative.openInApp(url) — force in-app + // Falls back to window.open in a plain mobile browser. addJavascriptInterface( object { @android.webkit.JavascriptInterface fun openExternal(url: String) { - webViewRef.post { openExternalUrl(url) } + webViewRef.post { routeOutbound(url) } + } + + @android.webkit.JavascriptInterface + fun openInApp(url: String) { + webViewRef.post { inAppUrl = url } } }, "ArchipelagoNative", @@ -252,10 +315,10 @@ fun WebViewScreen( request: WebResourceRequest?, ): Boolean { val url = request?.url?.toString() ?: return false - // Keep navigation within the Archipelago server + // Keep kiosk navigation (same origin incl. port) in place if (url.startsWith(serverUrl)) return false - // Open external URLs in the system browser - openExternalUrl(url) + // Same node (other port) → in-app; external → browser + routeOutbound(url) return true } } @@ -265,7 +328,9 @@ fun WebViewScreen( loadProgress = newProgress } - // Handle window.open() — open in system browser + // window.open() — e.g. the kiosk's "Open in new tab" + // for an app that can't be iframed. Capture the target + // URL via a throwaway WebView and route it ourselves. override fun onCreateWindow( view: WebView?, isDialog: Boolean, @@ -283,12 +348,12 @@ fun WebViewScreen( request: WebResourceRequest?, ): Boolean { val url = request?.url?.toString() ?: return true - openExternalUrl(url) + routeOutbound(url) return true } override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { - if (url != null) openExternalUrl(url) + if (url != null) routeOutbound(url) view?.stopLoading() } } @@ -350,6 +415,140 @@ 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 -> + InAppBrowser( + url = target, + serverUrl = serverUrl, + onClose = { inAppUrl = null }, + ) + } } } } + +/** + * Lightweight in-app browser used when the kiosk hands off an app that can't be + * shown in an iframe. Loads the app in a local WebView with a minimal top bar + * (close + title + escalate-to-real-browser). Same-host navigation stays here; + * any genuinely external link escapes to the phone's browser. + */ +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun InAppBrowser( + url: String, + serverUrl: String, + onClose: () -> Unit, +) { + val context = LocalContext.current + var browser by remember { mutableStateOf(null) } + var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) } + var progress by remember { mutableIntStateOf(0) } + var loading by remember { mutableStateOf(true) } + + // Back: walk the in-app history first, then close the overlay. + BackHandler { + val b = browser + if (b != null && b.canGoBack()) b.goBack() else onClose() + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(SurfaceBlack) + .windowInsetsPadding(WindowInsets.safeDrawing), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = stringResource(R.string.close), + tint = TextPrimary, + ) + } + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + color = TextPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) { + Icon( + imageVector = Icons.Default.OpenInBrowser, + contentDescription = stringResource(R.string.open_in_browser), + tint = TextMuted, + ) + } + } + + AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) { + LinearProgressIndicator( + progress = { progress / 100f }, + modifier = Modifier.fillMaxWidth(), + color = BitcoinOrange, + trackColor = SurfaceBlack, + ) + } + + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { ctx -> + WebView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + isVerticalScrollBarEnabled = false + isHorizontalScrollBarEnabled = false + + CookieManager.getInstance().setAcceptThirdPartyCookies(this, true) + applyArchipelagoSettings() + + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + progress = newProgress + } + + override fun onReceivedTitle(view: WebView?, t: String?) { + if (!t.isNullOrBlank()) title = t + } + } + + webViewClient = object : WebViewClient() { + override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) { + loading = true + } + + override fun onPageFinished(view: WebView?, u: String?) { + loading = false + } + + override fun shouldOverrideUrlLoading( + view: WebView?, + request: WebResourceRequest?, + ): Boolean { + 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 + openExternalUrl(ctx, u) + return true + } + } + + browser = this + loadUrl(url) + } + }, + ) + } +} diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml index 642bd4cd..d28fcec6 100644 --- a/Android/app/src/main/res/values/strings.xml +++ b/Android/app/src/main/res/values/strings.xml @@ -21,4 +21,6 @@ Retry Remote Control Use your phone as a keyboard and mouse for the kiosk + Close + Open in browser