feat: keep pinned embedded tabs warm via a persistent surface layer

Implements keep-warm (approach A) so a bottom-row embedded tab keeps its
full state across tab swaps, scoped to bottom-row apps per review.

Why a persistent layer: androidx.privacysandbox.ui's SandboxedSdkView
closes its session in onDetachedFromWindow, so a session torn down the
moment a tab leaves composition is unavoidable if the surface lives inside
the per-screen composable. Instead, a single app-shell overlay
(EmbeddedTabLayer) holds every warm session's SandboxedSdkView attached the
whole time — the active one positioned over the current tab's reserved
content area, the rest parked off-screen but alive. No provider changes
needed: the WebView never detaches, so its JS state survives.

- EmbeddedTabHost: process-level holder of warm sessions (keyed by
  FavoriteApp.id), the active id, and the active content bounds.
- EmbeddedSurfaceController unifies the browser + napplet controllers so the
  layer can attach/park/teardown either; the napplet controller pauses its
  applet while parked (onHidden) and on app-background.
- FavoriteWebAppScreen / FavoriteNappletScreen no longer host the surface;
  they reserve the content area (reporting window bounds) and drive the warm
  controller. The trusted napplet chrome stays in the main process.

Scope (per review): only bottom-bar favorites stay warm — a tab whose app
isn't a bottom-bar favorite is evicted (restarted) when it leaves
(EmbeddedTabHost.retainOnly, driven by the settings list). Genuine memory
pressure (onTrimMemory) drops all warm sessions; mere backgrounding does not.

Needs on-device verification of the surface overlay (alignment, touch
pass-through to the bars, warm re-show) — validated here by build + assemble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
This commit is contained in:
Claude
2026-06-23 15:32:15 +00:00
parent b373a297b6
commit e4f2c5305d
9 changed files with 371 additions and 47 deletions
@@ -22,9 +22,12 @@ package com.vitorpamplona.amethyst.ui.navigation
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -34,9 +37,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.IntentCompat
import androidx.core.util.Consumer
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.R
@@ -112,6 +117,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.list.ListOfEmojiPacksScreen
@@ -242,7 +248,17 @@ fun AppNavigation(
val nav = rememberNav()
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
BuildNavigation(accountViewModel, nav)
Box(Modifier.fillMaxSize()) {
BuildNavigation(accountViewModel, nav)
// Persistent layer that keeps pinned embedded tabs (browser / nsite / napplet) warm by
// holding their surfaces attached. Below the drawer (drawn by the layout above) and below
// dialogs (separate windows). API 30+ only, matching the embedded-surface feature.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val barFavoriteIds by accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
.collectAsStateWithLifecycle()
EmbeddedTabLayer(barFavoriteIds)
}
}
}
NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager)
@@ -36,6 +36,7 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
/**
* Client-side handle to the embedded browser. Binds [NappletBrowserService] (in the keyless `:napplet`
@@ -48,7 +49,7 @@ class EmbeddedBrowserController(
private val appContext: Context,
private val proxyPort: Int,
private val initialUseTor: Boolean,
) {
) : EmbeddedSurfaceController {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -88,8 +89,10 @@ class EmbeddedBrowserController(
}
}
override fun teardown() = unbind()
/** Hands the surface view to the controller; applies the adapter if it already arrived. */
fun attachView(view: SandboxedSdkView) {
override fun attachView(view: SandboxedSdkView) {
sandboxedSdkView = view
pendingAdapter?.let {
view.setAdapter(it)
@@ -34,12 +34,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.TextOverflow
@@ -52,16 +56,17 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
/**
* A pinned web client rendered as an **in-app tab**: the embedded `:napplet` browser surface fills the
* screen, but the app's bottom bar stays put, so switching to and from it is an ordinary tab swap — no
* new activity, no jarring task switch. The pop-out action hands the same URL to the full-screen
* [BrowserHostActivity] for users who want it as its own window/recents entry.
* A pinned web client rendered as an **in-app tab**. The embedded `:napplet` browser surface is drawn
* by the persistent [EmbeddedTabHost]/[com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer]
* layer, which keeps the session warm across tab swaps (the surface stays attached, just moved over the
* area this screen reserves). This screen owns only the chrome: a read-only title + Tor/reload, plus a
* pop-out to the full-screen [BrowserHostActivity]. Deliberately no editable address bar.
*
* Only [FavoriteApp.WebUrl][com.vitorpamplona.amethyst.commons.favorites.FavoriteApp.WebUrl] favorites
* reach this screen (they're the only ones pinnable to the bottom bar today); requires API 30+ for the
* cross-process surface.
* Only bottom-row favorites stay warm; if this URL isn't a bottom-bar favorite, its session is evicted
* (restarted) when the screen leaves. Requires API 30+ for the cross-process surface.
*/
@Composable
fun FavoriteWebAppScreen(
@@ -90,6 +95,8 @@ private fun EmbeddedFavoriteTab(
nav: INav,
) {
val context = LocalContext.current
// Matches FavoriteApp.WebUrl.id, so warm-keep membership lines up with the bottom-bar favorites.
val id = "url:$url"
var currentUrl by remember { mutableStateOf(url) }
var canGoBack by remember { mutableStateOf(false) }
@@ -98,10 +105,30 @@ private fun EmbeddedFavoriteTab(
var torOn by remember { mutableStateOf(proxyAvailable) }
val controller =
rememberBrowserController(startUrl = url) { newUrl, back ->
remember(id) {
EmbeddedTabHost.acquire(id) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
EmbeddedBrowserController(context.applicationContext, proxyPort, proxyPort > 0).also { it.bind(url) }
} as EmbeddedBrowserController
}
// Keep the URL/back callback fresh without re-binding the warm session.
SideEffect {
controller.onUrlChanged = { newUrl, back ->
if (newUrl != "about:blank") currentUrl = newUrl
canGoBack = back
}
}
val barFavoritesFlow = accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in barFavoritesFlow.value) EmbeddedTabHost.evict(id)
}
}
BackHandler(enabled = canGoBack) { controller.back() }
@@ -109,11 +136,7 @@ private fun EmbeddedFavoriteTab(
topBar = {
TopAppBar(
title = {
Text(
text = hostLabel(currentUrl),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(text = hostLabel(currentUrl), maxLines = 1, overflow = TextOverflow.Ellipsis)
},
actions = {
if (proxyAvailable) {
@@ -141,12 +164,12 @@ private fun EmbeddedFavoriteTab(
AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
EmbeddedBrowserSurface(
controller = controller,
modifier =
Modifier
.fillMaxSize()
.padding(padding),
// Reserve the content area; the warm surface is positioned over these bounds by the tab layer.
Box(
Modifier
.fillMaxSize()
.padding(padding)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
}
}
@@ -0,0 +1,49 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
/**
* The minimal surface controls [EmbeddedTabHost] needs to keep a session warm regardless of whether
* it's a browser ([com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedBrowserController])
* or an nsite/napplet ([com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.EmbeddedNappletController]).
*
* Warm-keep works by keeping the session's [SandboxedSdkView] **attached** to the window the whole
* time (just moved off-screen when not active) — the privacy-sandbox view only closes its session on
* detach, so an attached-but-hidden view stays alive. [onShown]/[onHidden] let a controller pause its
* applet's JS while hidden; [teardown] tears the session down for good (eviction).
*/
@RequiresApi(Build.VERSION_CODES.R)
interface EmbeddedSurfaceController {
fun attachView(view: SandboxedSdkView)
/** The session became the visible tab. */
fun onShown() {}
/** The session is warm but off-screen; a controller may pause its applet here. */
fun onHidden() {}
/** Permanently close the session (unbind the service); used on eviction. */
fun teardown()
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Rect
/**
* Process-level holder of **warm embedded sessions** — the persistent-surface-layer half of keep-warm.
* Each warm session's [SandboxedSdkView][androidx.privacysandbox.ui.client.view.SandboxedSdkView] is
* rendered by [EmbeddedTabLayer] and stays attached to the window the whole time, so its WebView (in
* the keyless `:napplet` process) keeps its full JS state; the active one is positioned over the
* current tab's content area, the rest sit off-screen but alive.
*
* Warm-keep is scoped to **bottom-row apps**: a session is retained only while its app is a bottom-bar
* favorite (see [retainOnly], driven by the bottom-bar settings). A favorite opened outside the bottom
* row restarts when it leaves, and a low-memory trim ([evictAll]) drops everything.
*
* State is Compose snapshot state so [EmbeddedTabLayer] recomposes as sessions / the active id / the
* content bounds change. Main-thread only.
*/
@RequiresApi(Build.VERSION_CODES.R)
object EmbeddedTabHost {
class Warm(
val id: String,
val controller: EmbeddedSurfaceController,
)
private val warm = mutableStateListOf<Warm>()
val sessions: List<Warm> get() = warm
/** Id of the session shown over the current content area, or null when no embedded tab is on top. */
var activeId by mutableStateOf<String?>(null)
private set
/** Window-space bounds of the active tab's reserved content area. */
var contentBounds by mutableStateOf(Rect.Zero)
private set
/** Returns the existing warm controller for [id], or creates + registers one via [factory]. */
fun acquire(
id: String,
factory: () -> EmbeddedSurfaceController,
): EmbeddedSurfaceController {
warm.firstOrNull { it.id == id }?.let { return it.controller }
val controller = factory()
warm.add(Warm(id, controller))
return controller
}
fun setActive(id: String) {
activeId = id
}
fun clearActiveIfMatches(id: String) {
if (activeId == id) activeId = null
}
fun reportBounds(bounds: Rect) {
contentBounds = bounds
}
fun evict(id: String) {
val w = warm.firstOrNull { it.id == id } ?: return
if (activeId == id) activeId = null
warm.remove(w)
w.controller.teardown()
}
/** Drops every warm session whose id isn't in [keep] (bottom-row membership + the active tab). */
fun retainOnly(keep: Set<String>) {
warm
.filter { it.id !in keep }
.forEach { evict(it.id) }
}
fun evictAll() {
activeId = null
val copy = warm.toList()
warm.clear()
copy.forEach { it.controller.teardown() }
}
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
/**
* The persistent surface layer: a full-window overlay (mounted once in the app shell, below the
* navigation drawer and dialogs) that renders **every** warm embedded session's [SandboxedSdkView] and
* keeps it attached. The active session is positioned over the current tab's reserved content area; the
* rest are parked off-screen but stay attached, so their sessions never detach/close — that's what
* preserves their state across tab swaps.
*
* [barFavoriteIds] are the favorites currently configured as bottom-bar tabs; warm-keep is scoped to
* them ([EmbeddedTabHost.retainOnly]), plus whatever is momentarily active.
*/
@RequiresApi(Build.VERSION_CODES.R)
@Composable
fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
val activeId = EmbeddedTabHost.activeId
// Keep only bottom-row apps warm (plus the active tab, even mid-removal). A favorite removed from
// the bar drops its warm session here.
LaunchedEffect(barFavoriteIds, activeId) {
EmbeddedTabHost.retainOnly(barFavoriteIds.toSet() + setOfNotNull(activeId))
}
val bounds = EmbeddedTabHost.contentBounds
var layerOrigin by remember { mutableStateOf(Offset.Zero) }
Box(
Modifier
.fillMaxSize()
.onGloballyPositioned { layerOrigin = it.positionInWindow() },
) {
EmbeddedTabHost.sessions.forEach { session ->
key(session.id) {
val active = session.id == activeId
LaunchedEffect(active) {
if (active) session.controller.onShown() else session.controller.onHidden()
}
val density = LocalDensity.current
val placement =
if (active && bounds.width > 0f && bounds.height > 0f) {
with(density) {
Modifier
.absoluteOffset(
(bounds.left - layerOrigin.x).toDp(),
(bounds.top - layerOrigin.y).toDp(),
).size(bounds.width.toDp(), bounds.height.toDp())
}
} else {
// Parked: tiny + far off-screen, but still attached so the session stays warm.
Modifier
.absoluteOffset(x = (-10000).dp)
.size(1.dp)
}
AndroidView(
factory = { ctx -> SandboxedSdkView(ctx).also { session.controller.attachView(it) } },
modifier = placement,
)
}
}
}
}
@@ -36,6 +36,7 @@ import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
/**
* Client-side handle to an embedded nsite/napplet. Binds [NappletHostService][com.vitorpamplona.amethyst.napplethost.NappletHostService]
@@ -51,7 +52,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
class EmbeddedNappletController(
private val appContext: Context,
private val params: Bundle,
) {
) : EmbeddedSurfaceController {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -92,7 +93,7 @@ class EmbeddedNappletController(
}
}
fun attachView(view: SandboxedSdkView) {
override fun attachView(view: SandboxedSdkView) {
sandboxedSdkView = view
pendingAdapter?.let {
view.setAdapter(it)
@@ -100,6 +101,12 @@ class EmbeddedNappletController(
}
}
override fun onShown() = resume()
override fun onHidden() = pause()
override fun teardown() = unbind()
private fun sendCreateSession() {
val msg =
Message.obtain(null, NappletEmbedContract.MSG_CREATE_SESSION).apply {
@@ -44,16 +44,16 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
@@ -65,16 +65,18 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
/**
* A favorited nsite/napplet rendered as an **in-app tab**: the verified-blob sandbox surface (hosted in
* the keyless `:napplet` process by `NappletHostService`) fills the screen while the app's bottom bar
* stays, so switching to/from it is an ordinary tab swap. The **trusted chrome** — the sandbox shield,
* the app name, and the "what it can access" sheet — is drawn here in the main process, around the
* surface, exactly because the sandbox must never draw chrome the user is meant to trust. The pop-out
* hands the app to the full-screen `NappletHostActivity`.
* A favorited nsite/napplet rendered as an **in-app tab**. The verified-blob sandbox surface (hosted in
* the keyless `:napplet` process by `NappletHostService`) is drawn by the persistent [EmbeddedTabHost]
* layer, which keeps the session warm across tab swaps — the surface stays attached and just moves over
* the area this screen reserves. The **trusted chrome** (sandbox shield, app name, "what it can access")
* is drawn here in the main process; the sandbox must never draw chrome the user is meant to trust. The
* pop-out hands the app to the full-screen `NappletHostActivity`.
*
* Requires API 30+ for the cross-process surface; the favorite is only reachable above that.
* Only bottom-row favorites stay warm; otherwise the session is evicted (restarted) when the screen
* leaves. Requires API 30+ for the cross-process surface.
*/
@Composable
fun FavoriteNappletScreen(
@@ -103,8 +105,10 @@ private fun EmbeddedNappletTab(
nav: INav,
) {
val context = LocalContext.current
// Matches FavoriteApp.NostrApp.id, so warm-keep membership lines up with the bottom-bar favorites.
val id = "nostr:$coordinate"
// Mint the verified launch params once (a fresh token per resolve); null until the event loads.
// Mint the verified launch params (a fresh token per resolve); null until the event loads.
val params = remember(coordinate) { FavoriteAppLauncher.embedParams(context, coordinate) }
if (params == null) {
UnavailableTab(coordinate, accountViewModel, nav)
@@ -119,9 +123,13 @@ private fun EmbeddedNappletTab(
var canGoBack by remember { mutableStateOf(false) }
var showAccess by remember { mutableStateOf(false) }
val controller = remember(coordinate) { EmbeddedNappletController(context.applicationContext, params) }
val controller =
remember(id) {
EmbeddedTabHost.acquire(id) {
EmbeddedNappletController(context.applicationContext, params).also { it.bind() }
} as EmbeddedNappletController
}
// Keep callbacks fresh without re-binding.
SideEffect {
controller.onStateChanged = { canGoBack = it }
controller.onNotice = { notice ->
@@ -129,13 +137,19 @@ private fun EmbeddedNappletTab(
}
}
DisposableEffect(coordinate) {
controller.bind()
onDispose { controller.unbind() }
val barFavoritesFlow = accountViewModel.settings.uiSettingsFlow.bottomBarFavoriteIds
DisposableEffect(id) {
EmbeddedTabHost.setActive(id)
onDispose {
EmbeddedTabHost.clearActiveIfMatches(id)
// Only bottom-row apps stay warm; anything else restarts when it leaves.
if (id !in barFavoritesFlow.value) EmbeddedTabHost.evict(id)
}
}
// Pause the applet's JS while the app is backgrounded, so an "allow always" napplet can't act on
// the user's behalf when they aren't looking — parity with NappletHostActivity's onPause gating.
// Pause the applet's JS while the app is backgrounded (parity with NappletHostActivity's onPause):
// an "allow always" napplet can't act on the user's behalf when they aren't looking. (The tab layer
// separately pauses it whenever it isn't the visible tab.)
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, controller) {
val observer =
@@ -184,12 +198,12 @@ private fun EmbeddedNappletTab(
AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { padding ->
AndroidView(
factory = { ctx -> SandboxedSdkView(ctx).also { controller.attachView(it) } },
modifier =
Modifier
.fillMaxSize()
.padding(padding),
// Reserve the content area; the warm surface is positioned over these bounds by the tab layer.
Box(
Modifier
.fillMaxSize()
.padding(padding)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
}
}