mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
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:
co-authored by
Claude Opus 4.8
parent
2df0c4d6bb
commit
fb4a2e0858
+65
@@ -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) }
|
||||
|
||||
+74
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+11
@@ -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"
|
||||
|
||||
+45
-2
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user