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(