From fb4a2e0858661688196ace237379ea2989375b5d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Wed, 24 Jun 2026 14:03:33 -0400 Subject: [PATCH] fix(browser): recover embedded web-app tab stuck on about:blank MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A favorite web app pinned to the bottom bar at runtime could come up on a blank surface (black, then white after a manual reload) and never recover. Its warm browser session settled on about:blank — its real URL was dropped on the way in — and the chrome Reload button calls WebView.reload(), which just re-loads about:blank instead of the favorite's page. Fixes: - The provider now reports main-frame load state (start/finish/error) over a new MSG_LOAD_STATE. When a favorite session settles on about:blank while it has a real URL, the controller re-navigates to the canonical URL once. Gated on a real startUrl, so the generic browser's intentional about:blank new-tab page is left alone. Adds controller.retry() (navigate-to-canonical, not reload) for the chrome retry path. - FavoriteWebAppScreen now draws a loading spinner until a real page paints, and an error + Retry overlay when the main frame fails or the load stalls (12s) — so a slow, blank, or failed load is no longer a silent black/white void. Scoped to the browser/WebUrl path; the napplet/nsite path is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/EmbeddedBrowserController.kt | 65 ++++++++++++++++ .../loggedIn/browser/FavoriteWebAppScreen.kt | 76 ++++++++++++++++++- amethyst/src/main/res/values/strings.xml | 1 + .../napplethost/NappletBrowserContract.kt | 11 +++ .../napplethost/NappletBrowserService.kt | 47 +++++++++++- 5 files changed, 196 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt index a8060594aa..3396eafb59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserController.kt @@ -42,6 +42,16 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import org.json.JSONObject import java.util.concurrent.atomic.AtomicLong +/** + * Main-frame load state of an embedded browser session. [hasLoadedReal] flips true once a non-blank page + * has finished, so a screen re-entering a warm, already-loaded tab doesn't flash a spinner. + */ +data class LoadStatus( + val isLoading: Boolean = false, + val failed: Boolean = false, + val hasLoadedReal: Boolean = false, +) + /** * Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet` * process), hands its `SandboxedUiAdapter` to a [SandboxedSdkView] so the remote WebView renders inside @@ -64,6 +74,16 @@ class EmbeddedBrowserController( private var pendingAdapter: SandboxedUiAdapter? = null private var startUrl: String = "about:blank" + private var hasLoadedReal = false + private var blankRecovered = false + + /** Last known main-frame load state, so a re-entering screen renders the right overlay immediately. */ + var loadStatus: LoadStatus = LoadStatus() + private set + + /** Notified on the main thread whenever [loadStatus] changes. */ + var onLoadStatusChanged: ((LoadStatus) -> Unit)? = null + // A single NappletBrowserService instance serves every embedded browser tab, so each controller // stamps its own id on every message; the provider uses it to route controls/updates to this tab. private val sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" @@ -105,6 +125,7 @@ class EmbeddedBrowserController( pendingAdapter = null onUrlChanged = null onImeEvent = null + onLoadStatusChanged = null } override fun teardown() = unbind() @@ -154,6 +175,12 @@ class EmbeddedBrowserController( val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true parseImeEvent(payload)?.let { event -> onImeEvent?.invoke(event) } } + NappletBrowserContract.MSG_LOAD_STATE -> { + val isLoading = msg.data?.getBoolean(NappletBrowserContract.KEY_IS_LOADING, false) ?: false + val failed = msg.data?.getBoolean(NappletBrowserContract.KEY_LOAD_FAILED, false) ?: false + val loadedUrl = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() + onLoadState(isLoading, failed, loadedUrl) + } else -> return false } return true @@ -163,6 +190,44 @@ class EmbeddedBrowserController( fun reload() = send(NappletBrowserContract.MSG_RELOAD) {} + /** + * User-triggered recovery for a stuck, blank, or failed session: reload the canonical [startUrl] from + * scratch. Unlike [reload] (which re-fetches whatever the WebView currently shows — `about:blank` for a + * session that never got its URL), this re-navigates to the favorite's real URL. + */ + fun retry() { + blankRecovered = false + hasLoadedReal = false + publishLoadStatus(LoadStatus(isLoading = true)) + navigate(startUrl) + } + + private fun onLoadState( + isLoading: Boolean, + failed: Boolean, + loadedUrl: String, + ) { + // A favorite whose session settled on about:blank never received its real URL (a warm session built + // before the URL was wired through). Re-navigate once to the canonical URL — reload() can't fix this + // because it would just reload about:blank. Scoped to a real startUrl, so the generic browser's + // intentional about:blank new-tab page is left alone. + if (!isLoading && !failed && loadedUrl.isBlankPage() && !startUrl.isBlankPage() && !blankRecovered) { + blankRecovered = true + publishLoadStatus(LoadStatus(isLoading = true)) + navigate(startUrl) + return + } + if (!isLoading && !failed && !loadedUrl.isBlankPage()) hasLoadedReal = true + publishLoadStatus(LoadStatus(isLoading = isLoading, failed = failed, hasLoadedReal = hasLoadedReal)) + } + + private fun publishLoadStatus(status: LoadStatus) { + loadStatus = status + onLoadStatusChanged?.invoke(status) + } + + private fun String.isBlankPage() = isEmpty() || this == "about:blank" + fun back() = send(NappletBrowserContract.MSG_BACK) {} fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt index 48c9f713c0..9e5ef1c2df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/FavoriteWebAppScreen.kt @@ -24,14 +24,23 @@ import android.net.Uri import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,6 +53,8 @@ import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher @@ -56,6 +67,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost +import kotlinx.coroutines.delay /** * A pinned web client rendered as an **in-app tab**. The embedded `:napplet` browser surface is drawn @@ -111,12 +123,29 @@ private fun EmbeddedFavoriteTab( EmbeddedTabFactory.acquireBrowser(context, url, backgroundColor) } - // Keep the URL/back callback fresh (cheap, needs the latest closure). + // Session-scoped load state (read from the warm controller, so re-entering an already-loaded tab + // doesn't flash a spinner over working content). + var status by remember(id) { mutableStateOf(controller.loadStatus) } + var timedOut by remember(id) { mutableStateOf(false) } + + // Keep the URL/back/load callbacks fresh (cheap, needs the latest closures). SideEffect { controller.onUrlChanged = { newUrl, back -> if (newUrl != "about:blank") currentUrl = newUrl canGoBack = back } + controller.onLoadStatusChanged = { status = it } + } + + // Safety net: if no real page has painted and nothing is actively loading after a grace period, treat + // the session as stuck and surface a retry (e.g. a surface that never opened). Restarts on every load + // state change, so it only fires after genuine silence. + LaunchedEffect(id, status) { + timedOut = false + if (!status.hasLoadedReal && !status.failed) { + delay(12_000) + timedOut = true + } } // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. @@ -163,7 +192,50 @@ private fun EmbeddedFavoriteTab( .fillMaxSize() .padding(padding) .onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) }, - ) + ) { + // The embedded WebView surface is drawn (z-below) by the tab layer over these bounds. Until a + // real page paints, cover it with a spinner — or an error/retry when the load failed or stalled + // — so a slow, blank, or failed load isn't a bare black/white void. + if (!status.hasLoadedReal) { + EmbeddedLoadOverlay( + failed = status.failed || timedOut, + onRetry = { + timedOut = false + controller.retry() + }, + ) + } + } + } +} + +@Composable +private fun BoxScope.EmbeddedLoadOverlay( + failed: Boolean, + onRetry: () -> Unit, +) { + Column( + modifier = + Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + if (failed) { + Text( + text = stringResource(R.string.embedded_tab_load_failed), + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(stringResource(R.string.retry)) + } + } else { + CircularProgressIndicator() + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 413b463983..b9dcf5fa42 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -672,6 +672,7 @@ Loading over Tor. Tap to use the open web. Loading over the open web. Tap to use Tor. The in-app browser needs Android 11 or newer. + Couldn\'t load this app. Open Clear Favorites diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index ecffc88973..5323c73edf 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -63,6 +63,17 @@ object NappletBrowserContract { /** Client → provider: an IME editing op for the focused field; raw JSON in [KEY_IME_PAYLOAD]. */ const val MSG_IME_OP = 9 + /** + * Provider → client: the main-frame load state changed. Carries [KEY_IS_LOADING] (a navigation is in + * flight), [KEY_LOAD_FAILED] (the main frame errored), and [KEY_URL] (the page it settled on). Lets + * the main process draw a loading spinner / error overlay over the embedded surface, and recover a + * favorite whose session came up on a blank page (re-navigate to its real URL). + */ + const val MSG_LOAD_STATE = 10 + + const val KEY_IS_LOADING = "isLoading" + const val KEY_LOAD_FAILED = "loadFailed" + const val KEY_IME_PAYLOAD = "imePayload" const val KEY_URL = "url" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 527a3a6b5d..1ced7fc4ba 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -34,6 +34,7 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.util.Log +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebSettings import android.webkit.WebView @@ -83,6 +84,10 @@ class NappletBrowserService : Service() { var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 + // Last main-frame error state, pushed to the client so it can show an error/retry overlay over + // the surface (the embedded surface has no error page of its own). + var loadFailed = false + // Per visited origin: its broker-minted launch token, the requests queued until it arrives, and // the origins a mint is already in flight for — so NIP-07 consent is scoped per site, per tab. val originTokens = mutableMapOf() @@ -279,7 +284,12 @@ class NappletBrowserService : Service() { view: WebView, url: String, favicon: android.graphics.Bitmap?, - ) = pushUrl(tab, view) + ) { + // A new main-frame navigation cleared any prior error. + tab?.loadFailed = false + pushUrl(tab, view) + pushLoadState(tab, view, isLoading = true) + } override fun doUpdateVisitedHistory( view: WebView, @@ -290,7 +300,40 @@ class NappletBrowserService : Service() { override fun onPageFinished( view: WebView, url: String, - ) = pushUrl(tab, view) + ) { + pushUrl(tab, view) + pushLoadState(tab, view, isLoading = false) + } + + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + // Only a main-frame failure blanks the page; sub-resource errors (a missing image, a blocked + // tracker) are irrelevant to whether the app opened. + if (!request.isForMainFrame) return + tab?.loadFailed = true + pushLoadState(tab, view, isLoading = false) + } + } + + /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ + private fun pushLoadState( + tab: BrowserTab?, + view: WebView, + isLoading: Boolean, + ) { + val message = + Message.obtain(null, NappletBrowserContract.MSG_LOAD_STATE).apply { + data = + Bundle().apply { + putBoolean(NappletBrowserContract.KEY_IS_LOADING, isLoading) + putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, tab?.loadFailed ?: false) + putString(NappletBrowserContract.KEY_URL, view.url.orEmpty()) + } + } + runCatching { tab?.clientMessenger?.send(message) } } private fun pushUrl(