From e4f2c5305d66cb18a03ad381083df29a51e753ba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 15:32:15 +0000 Subject: [PATCH] feat: keep pinned embedded tabs warm via a persistent surface layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- .../com/vitorpamplona/amethyst/Amethyst.kt | Bin 6548 -> 7407 bytes .../amethyst/ui/navigation/AppNavigation.kt | 18 ++- .../browser/EmbeddedBrowserController.kt | 7 +- .../loggedIn/browser/FavoriteWebAppScreen.kt | 61 ++++++---- .../embed/EmbeddedSurfaceController.kt | 49 ++++++++ .../screen/loggedIn/embed/EmbeddedTabHost.kt | 106 ++++++++++++++++++ .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 106 ++++++++++++++++++ .../favorites/EmbeddedNappletController.kt | 11 +- .../favorites/FavoriteNappletScreen.kt | 60 ++++++---- 9 files changed, 371 insertions(+), 47 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 45f926865b6f75a93a675045760958a451bbe055..51aba7e3515e90a2f6813d4ca8eb11134c924877 100644 GIT binary patch delta 808 zcma))L2J}N6vq$l9^8{4z3Ang$}ZMf)Ki5OViMZmCXtXuJS~&tH61#cS!R;RVwdzg zq-Rf_Mey!t@H2Q8ym@xA*4P%Y&LIq$|NH&lf99P(`M&@0!{B}@MTw#$wk%dsn4L{I zPiT6gAGhxYKiV&Y+vAE6ohn2@xXg3RJVCHXFe7gF%`l5;awv7#J{sO@KM(JGdO!Sj zw*j-JQ*e}A!5gXy*wz>|lr#a2S~Dp$R3bwKIf{x2gaQkx*056MxKaq?3{%z#pTY^^ z1*j4w<4_*ssPpKS@u{@INClS2lw=t+3R6y5p*vwQA>bfq2`XA5UqcYaAO$SE<)P;| zZZLgr*)I;Fa1l80_3Yf#NR4pBObbSaHLCSxv+ja&B~9;4FlT&CdY}5TYS#bm@@{KjHiN%B8;K*&U;2&f zhtcb0vV$ZhbH^Q20({$0+FPa}ZZe+#ojg)^u?9hKX wcVo8?#g1UyPtL8~U1;}r9zKgE@V1w9nXgSP!J4I|#rb&B%QoBHX0#do1mutYEC2ui delta 21 dcmaEFImLLxI+n>dS)Og?V*4Y^Qp;7#1psWT2#f## diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 461a8911e6..43cfd23c86 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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) 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 f00ec8a8bd..c9e6c9e66f 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 @@ -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) 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 227f8a60c4..5821a00d9b 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 @@ -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()) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt new file mode 100644 index 0000000000..d9b0557201 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedSurfaceController.kt @@ -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() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt new file mode 100644 index 0000000000..f69f776aa3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -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() + val sessions: List 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(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) { + 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() } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt new file mode 100644 index 0000000000..fb777c45d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -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) { + 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, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt index 5459f40ee6..c7d9c971af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNappletController.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt index 396e8dc45e..2b68e27b6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/FavoriteNappletScreen.kt @@ -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()) }, ) } }