mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
Binary file not shown.
@@ -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)
|
||||
|
||||
+5
-2
@@ -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)
|
||||
|
||||
+42
-19
@@ -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()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+49
@@ -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()
|
||||
}
|
||||
+106
@@ -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() }
|
||||
}
|
||||
}
|
||||
+106
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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 {
|
||||
|
||||
+37
-23
@@ -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()) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user