fix(browser): recover embedded web-app tab stuck on about:blank

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) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-06-24 14:03:33 -04:00
co-authored by Claude Opus 4.8
parent 2df0c4d6bb
commit fb4a2e0858
5 changed files with 196 additions and 4 deletions
@@ -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) }
@@ -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()
}
}
}
+1
View File
@@ -672,6 +672,7 @@
<string name="browser_tor_on">Loading over Tor. Tap to use the open web.</string>
<string name="browser_tor_off">Loading over the open web. Tap to use Tor.</string>
<string name="browser_unsupported">The in-app browser needs Android 11 or newer.</string>
<string name="embedded_tab_load_failed">Couldn\'t load this app.</string>
<string name="browser_go">Open</string>
<string name="browser_clear">Clear</string>
<string name="browser_favorites">Favorites</string>
@@ -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"
@@ -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<String, String>()
@@ -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(