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