fix(browser): a rebuilt tab resumes the page it showed

Two ways a browser tab came back on its start URL instead of where the
user was, both seen on the emulator:
- Every re-created session (after a renderer or `:napplet` death, or a
  Tor toggle) opened `startUrl`, although the controller tracks `lastUrl`.
  New sessions now resume `lastUrl`. Only the user's Retry starts over at
  `startUrl`, which is what it is for.
- The memory trim Android sends about a minute into the background
  (TRIM_MEMORY_BACKGROUND) runs rebuildAll(), which built new controllers
  and lost the page, history and desktop/zoom/Tor choices of every pinned
  site. rebuildAll(keepPages = true) now hands each browser tab's page
  and settings to its rebuilt controller. The theme rebuild keeps them
  too; an account switch never does, so the next account can't open the
  previous one's pages.

Verified on the emulator with a pinned tab moved off its start URL (P1,
desktop mode, start URL P2):
- am send-trim-memory BACKGROUND: the WebView was destroyed and rebuilt,
  and the tab came back on P1 in desktop mode (before: P2, mobile).
- Force-stopping the WebView provider (renderer and `:napplet` died):
  it recovered to P1 in desktop mode (before: P2).
- Tor toggle: stays on P1 (before: the start URL).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-09-30 19:11:31 -04:00
co-authored by Claude Opus 5.5
parent 6962514721
commit 38526fe810
4 changed files with 60 additions and 7 deletions
@@ -248,7 +248,7 @@ class Amethyst : Application() {
// memory stays freed until the user comes back.
val pressure = level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.rebuildAll()
EmbeddedTabHost.rebuildAll(keepPages = true)
}
}
}
@@ -166,6 +166,24 @@ class EmbeddedWebAppController(
var lastCanGoForward = false
private set
/** A tab's page and per-tab settings, carried to the controller that replaces this one on a rebuild. */
class PageSnapshot(
val url: String?,
val useTor: Boolean,
val textZoom: Int,
val desktopSite: Boolean,
)
fun snapshot() = PageSnapshot(lastUrl, useTor, textZoom, desktopSite)
/** Takes over a torn-down predecessor's page and settings; call before [bind], which creates the session. */
fun restore(snapshot: PageSnapshot) {
lastUrl = snapshot.url
useTor = snapshot.useTor
textZoom = snapshot.textZoom
desktopSite = snapshot.desktopSite
}
/** The user's per-tab settings as last set, for a screen coming back to this tab. */
val isTorOn: Boolean get() = useTor
@@ -462,6 +480,17 @@ class EmbeddedWebAppController(
publishLoadStatus(EmbeddedLoadStatus(isLoading = true))
}
// Set by the user's Retry: the next session starts over at [startUrl]. Every other re-creation (a crashed
// renderer, a `:napplet` restart, a memory-trim rebuild) resumes the page the user was on.
private var restartAtStart = false
/** Where a new session opens: the page on screen before it was lost, else the tab's own [startUrl]. */
private fun sessionUrl(): String {
val resume = lastUrl?.takeUnless { restartAtStart || it.isBlankPage() }
restartAtStart = false
return resume ?: startUrl
}
private fun sendCreateSession() {
awaitingReady = true
uiDisplayed = false
@@ -471,7 +500,7 @@ class EmbeddedWebAppController(
data =
Bundle().apply {
putString(NappletBrowserContract.KEY_SESSION_ID, sessionId)
putString(NappletBrowserContract.KEY_URL, startUrl)
putString(NappletBrowserContract.KEY_URL, sessionUrl())
putInt(NappletBrowserContract.KEY_PROXY_PORT, proxyPort())
putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor)
putInt(NappletBrowserContract.KEY_BG_COLOR, backgroundColor)
@@ -683,7 +712,12 @@ class EmbeddedWebAppController(
recovery.clearPending()
showRecovering()
// A surface that never opened has nothing to navigate: only a new session can paint it.
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) rearmSession() else navigate(startUrl)
if (sessionDead || (sandboxedSdkView != null && !uiDisplayed)) {
restartAtStart = true
rearmSession()
} else {
navigate(startUrl)
}
}
private fun onLoadState(
@@ -73,7 +73,10 @@ object EmbeddedTabFactory {
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also { it.bind(url) }
EmbeddedWebAppController(context.applicationContext, ::currentTorPort, initialUseTor, backgroundColor, theme).also {
EmbeddedTabHost.takePageSnapshot(webAppId(url))?.let(it::restore)
it.bind(url)
}
} as EmbeddedWebAppController
/**
@@ -33,6 +33,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController
/**
* Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm.
@@ -178,7 +179,7 @@ object EmbeddedTabHost {
val previous = builtDark
builtDark = dark
// The first report only records what the sessions (built from the same preference) already use.
if (previous != null && previous != dark) rebuildAll()
if (previous != null && previous != dark) rebuildAll(keepPages = true)
}
/**
@@ -332,16 +333,31 @@ object EmbeddedTabHost {
* screen and the preloader re-acquire freshly built sessions. This keeps [activeId], so the visible tab
* re-activates the instant its screen re-acquires — the user just sees the current tab reload, not a
* blanked-out surface.
*
* [keepPages]: each browser tab's rebuilt controller resumes the page it showed, with the user's Tor, zoom
* and desktop choices ([EmbeddedWebAppController.PageSnapshot]). Android sends the memory trim routinely,
* about a minute into the background, so without this every pinned site came back on its start URL. Never
* across an account switch: the next account must not open the previous one's pages.
*/
fun rebuildAll() {
fun rebuildAll(keepPages: Boolean) {
// Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto.
keyboardUpOnLeave.clear()
val copy = warm.toList()
warm.clear()
pageSnapshots.clear()
if (keepPages) {
copy.forEach { w -> (w.controller as? EmbeddedWebAppController)?.let { pageSnapshots[w.id] = it.snapshot() } }
}
copy.forEach { it.controller.teardown() }
rebuildEpoch += 1
}
// Page state carried from a torn-down browser tab to its rebuilt controller (see [rebuildAll]).
private val pageSnapshots = mutableMapOf<String, EmbeddedWebAppController.PageSnapshot>()
/** The page state [rebuildAll] saved for tab [id], handed over once. */
fun takePageSnapshot(id: String): EmbeddedWebAppController.PageSnapshot? = pageSnapshots.remove(id)
/**
* Account the warm sessions were built for, as the opaque WebView storage-profile name (null while
* logged out). Kept HERE, next to the sessions it describes, rather than in a composable's `remember`:
@@ -372,6 +388,6 @@ object EmbeddedTabHost {
builtForProfile = profileName
// Seeding on the first call (app start) must not bump the epoch: nothing is stale yet, and a
// needless bump would restart the preload sweep that is just getting going.
if (!isFirstCall) rebuildAll()
if (!isFirstCall) rebuildAll(keepPages = false)
}
}