feat: floating control puck for running app surfaces + remember Tor per web client

Replace the full-width top bar on every running app surface (embedded web/nsite/
napplet tabs and the full-screen browser + napplet activities) with a small
floating control puck. Apps already title themselves, so instead of repeating
the name we keep one always-visible trusted marker — the sandbox shield for
napplets/nsites (the anti-phishing affordance the page can't draw over), a globe
for the plain browser — that expands on tap to reveal the actions (Tor, reload,
pop-out, access sheet, close).

- New shared AppControlPuck composable backs the three Compose surfaces;
  NappletHostActivity gets the native-View equivalent.
- Embedded tabs inset their reserved surface bounds by the puck height
  (AppControlPuckReserve) so the warm surface, drawn over those bounds above the
  nav tree, doesn't cover the puck. Full-screen activities float it on top.
- EmbeddedTabTopBar is removed (no longer used).

Also remember the Tor on/off choice per web client: some sites' servers reject
Tor exits, so a user opting one out must have it stick. New WebUrlNetworkRegistry
(main process, keyed by host, device-local) mirrors NappletNetworkRegistry; the
browser reads it for the initial route and writes on toggle, in both the embedded
tab and the full-screen browser.

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 21:29:15 +00:00
parent 2115129a72
commit cb8bbad38d
10 changed files with 406 additions and 203 deletions
@@ -0,0 +1,91 @@
/*
* 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.napplet
import android.content.Context
import android.net.Uri
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.webUrlNetworkDataStore by preferencesDataStore(name = "weburl_network")
/**
* Per-web-client network-routing preference: whether a favorited URL / browsed site routes through
* **Tor** (the default when Tor is active) or over the **open web**. The web-client sibling of
* [NappletNetworkRegistry] — same model, but keyed by the site's **host** (so every page of a site
* shares the choice and it survives across launches).
*
* This exists because some sites' servers reject Tor exit connections; the user toggles such a site to
* the open web once from its control puck, and we must remember that so it doesn't break again next
* time. Absent = Tor (the safe default).
*
* Lives only in the **main process** (where the browser chrome runs); an in-memory map is authoritative
* for the session with write-through persistence.
*/
object WebUrlNetworkRegistry {
private const val OPEN_WEB = "OPEN"
private const val TOR = "TOR"
// host -> useTor. Absent means "never chosen" -> Tor.
private val modes = ConcurrentHashMap<String, Boolean>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
/** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
ctx.webUrlNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
}
}
/** The host key for [url] (e.g. `vitorpamplona.com`), or the raw string if it has no host. */
fun hostKeyOf(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url
/** Whether the site behind [url] routes through Tor. Defaults to true (Tor) for any site never set. */
fun useTor(url: String): Boolean = modes[hostKeyOf(url)] ?: true
/** Records [useTor] for the host behind [url] in memory and persists it. */
fun set(
url: String,
useTor: Boolean,
) {
val host = hostKeyOf(url)
modes[host] = useTor
val ctx = appContext ?: return
scope.launch {
ctx.webUrlNetworkDataStore.edit { it[stringPreferencesKey(host)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -30,26 +30,28 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.napplet.SandboxForegroundHold
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.AppControlPuck
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.TorToggleButton
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
@@ -112,7 +114,6 @@ class BrowserHostActivity : ComponentActivity() {
}
@RequiresApi(Build.VERSION_CODES.R)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BrowserHostScreen(
startUrl: String,
@@ -122,7 +123,8 @@ private fun BrowserHostScreen(
var canGoBack by remember { mutableStateOf(false) }
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
var torOn by remember { mutableStateOf(proxyAvailable) }
// Start from this site's remembered Tor choice (some servers reject Tor exits, so an opt-out sticks).
var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(startUrl)) }
val controller =
rememberBrowserController(startUrl = startUrl) { url, back ->
@@ -133,42 +135,42 @@ private fun BrowserHostScreen(
// In-page back first; once there's no page history, the system back closes the activity.
BackHandler(enabled = canGoBack) { controller.back() }
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
IconButton(onClick = onClose) {
Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringResource(R.string.back))
}
},
title = {
Text(
text = hostOf(currentUrl),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
actions = {
if (proxyAvailable) {
TorToggleButton(torOn) {
torOn = !torOn
controller.setTor(torOn)
}
}
IconButton(onClick = { controller.reload() }) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
},
// No top bar — the page titles itself. The surface fills the safe area; a floating puck (globe =
// trusted external-web marker, tap to reveal Tor/reload/close) carries the actions.
Scaffold { padding ->
Box(
Modifier
.fillMaxSize()
.padding(padding),
) {
EmbeddedBrowserSurface(
controller = controller,
modifier = Modifier.fillMaxSize(),
)
},
) { padding ->
EmbeddedBrowserSurface(
controller = controller,
modifier =
Modifier
.fillMaxSize()
.padding(padding),
)
AppControlPuck(
trustedIcon = MaterialSymbols.Public,
trustedTint = MaterialTheme.colorScheme.onSurfaceVariant,
trustedDescription = hostOf(currentUrl),
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
) {
if (proxyAvailable) {
TorToggleButton(torOn) {
torOn = !torOn
controller.setTor(torOn)
WebUrlNetworkRegistry.set(startUrl, torOn)
}
}
IconButton(onClick = { controller.reload() }) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
IconButton(onClick = onClose) {
Icon(MaterialSymbols.Close, contentDescription = stringResource(R.string.back))
}
}
}
}
}
@@ -33,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
/**
* Reusable, chrome-free embedded-browser surface. The page renders in the keyless `:napplet` process
@@ -70,7 +71,9 @@ fun rememberBrowserController(
val controller =
remember {
EmbeddedBrowserController(context.applicationContext, proxyPort, proxyPort > 0, backgroundColor)
// Honor this site's remembered Tor choice on first load (some servers reject Tor exits).
val initialUseTor = proxyPort > 0 && WebUrlNetworkRegistry.useTor(startUrl)
EmbeddedBrowserController(context.applicationContext, proxyPort, initialUseTor, backgroundColor)
}
// Keep the callback current without re-binding the service.
@@ -27,6 +27,7 @@ import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -44,24 +45,29 @@ 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.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher
import com.vitorpamplona.amethyst.napplet.WebUrlNetworkRegistry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar
import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
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.AppControlPuck
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.AppControlPuckReserve
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabTopBar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.TorToggleButton
/**
* 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. This screen owns only the chrome — the shared
* [EmbeddedTabTopBar] with the Tor onion as its leading affordance, plus reload + pop-out. Deliberately
* no editable address bar.
* layer, which keeps the session warm across tab swaps. This screen owns only the chrome — a floating
* [AppControlPuck] (globe marker, expands to Tor/reload/pop-out) instead of a top bar. Deliberately no
* editable address bar.
*
* 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.
@@ -99,7 +105,9 @@ private fun EmbeddedFavoriteTab(
var canGoBack by remember { mutableStateOf(false) }
val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null }
var torOn by remember { mutableStateOf(proxyAvailable) }
// Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user
// can opt one out and it must stick). Only meaningful when Tor is actually available.
var torOn by remember { mutableStateOf(proxyAvailable && WebUrlNetworkRegistry.useTor(url)) }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
@@ -107,7 +115,8 @@ private fun EmbeddedFavoriteTab(
remember(id) {
EmbeddedTabHost.acquire(id) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
EmbeddedBrowserController(context.applicationContext, proxyPort, proxyPort > 0, backgroundColor).also { it.bind(url) }
val initialUseTor = proxyPort > 0 && WebUrlNetworkRegistry.useTor(url)
EmbeddedBrowserController(context.applicationContext, proxyPort, initialUseTor, backgroundColor).also { it.bind(url) }
} as EmbeddedBrowserController
}
@@ -132,32 +141,50 @@ private fun EmbeddedFavoriteTab(
BackHandler(enabled = canGoBack) { controller.back() }
Scaffold(
topBar = {
EmbeddedTabTopBar(
title = hostLabel(currentUrl),
leading = {
if (proxyAvailable) {
TorToggleButton(torOn) {
torOn = !torOn
controller.setTor(torOn)
}
}
},
onReload = { controller.reload() },
onPopOut = { FavoriteAppLauncher.launchUrl(context, url) },
)
},
bottomBar = {
AppBottomBar(Route.FavoriteWebApp(url), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { 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()) },
)
.padding(padding),
) {
// Reserve the content area; the warm surface is positioned over these bounds by the tab
// layer. Inset the top by the puck's height so the surface (drawn over these bounds, above
// the nav tree) doesn't cover the floating puck.
Box(
Modifier
.fillMaxSize()
.padding(top = AppControlPuckReserve)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
// Floating controls instead of a top bar — the web client already titles itself. The globe is
// the trusted "external web page" marker (the page can't draw over it); tap to reveal actions.
AppControlPuck(
trustedIcon = MaterialSymbols.Public,
trustedTint = MaterialTheme.colorScheme.onSurfaceVariant,
trustedDescription = hostLabel(currentUrl),
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
) {
if (proxyAvailable) {
TorToggleButton(torOn) {
torOn = !torOn
controller.setTor(torOn)
WebUrlNetworkRegistry.set(url, torOn)
}
}
IconButton(onClick = { controller.reload() }) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
IconButton(onClick = { FavoriteAppLauncher.launchUrl(context, url) }) {
Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window))
}
}
}
}
}
@@ -0,0 +1,101 @@
/*
* 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 androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
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.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
/**
* Vertical space a collapsed [AppControlPuck] occupies (the 48dp touch target + its 8dp inset). Embedded
* tabs inset their reserved surface bounds by this at the top so the warm surface — which the tab layer
* draws *over* those bounds, above the whole nav tree — doesn't cover the puck. Full-screen activities
* don't need it: there the puck is a sibling drawn on top of the surface.
*/
val AppControlPuckReserve: Dp = 56.dp
/**
* A floating, collapsible control chip for a running app surface (web client / nsite / napplet) — the
* PWA-inspired replacement for a full-width top bar. Apps already title themselves, so instead of a bar
* that repeats the name we float a single small puck in a corner that:
*
* - **always shows the trusted marker** (the sandbox shield for napplets/nsites, a lock for the plain
* browser) — this is the anti-phishing affordance the sandboxed page can never draw over, so it must
* stay visible even collapsed, and
* - **expands on tap** to reveal the surface's actions (reload, Tor, pop-out, access sheet, close).
*
* Stateless apart from its own expanded/collapsed toggle: the caller supplies the trusted icon and the
* action buttons, so the same puck backs every surface (embedded tab + full-screen activity).
*/
@Composable
fun AppControlPuck(
trustedIcon: MaterialSymbol,
trustedTint: Color,
trustedDescription: String,
modifier: Modifier = Modifier,
actions: @Composable RowScope.() -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Surface(
modifier = modifier,
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
contentColor = MaterialTheme.colorScheme.onSurface,
tonalElevation = 3.dp,
shadowElevation = 4.dp,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// The trusted marker doubles as the expand/collapse toggle. It stays put; the actions slide
// out from behind it so the marker is always anchored in the corner.
IconButton(onClick = { expanded = !expanded }) {
Icon(trustedIcon, contentDescription = trustedDescription, tint = trustedTint)
}
AnimatedVisibility(
visible = expanded,
enter = expandHorizontally() + fadeIn(),
exit = shrinkHorizontally() + fadeOut(),
) {
Row(verticalAlignment = Alignment.CenterVertically, content = actions)
}
}
}
}
@@ -1,65 +0,0 @@
/*
* 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 androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
/**
* The shared chrome for an embedded web tab — the browser/web-app tab and the nsite/napplet tab use the
* same bar so they read as one consistent surface: a **leading** affordance specific to the surface
* (the napplet/nsite tab puts its sandbox-access **shield** here; the web-app tab puts the Tor onion),
* the title, **reload**, and **open-in-own-window**.
*
* Tor uses the app's onion (`ic_tor`) rather than the shield, so the shield always means "sandbox
* access" and is never mistaken for a Tor toggle.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EmbeddedTabTopBar(
title: String,
leading: @Composable () -> Unit,
onReload: () -> Unit,
onPopOut: () -> Unit,
) {
TopAppBar(
navigationIcon = leading,
title = {
Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis)
},
actions = {
IconButton(onClick = onReload) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
IconButton(onClick = onPopOut) {
Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window))
}
},
)
}
@@ -66,8 +66,9 @@ import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds
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.AppControlPuck
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.AppControlPuckReserve
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabTopBar
/**
* A favorited nsite/napplet rendered as an **in-app tab**. The verified-blob sandbox surface (hosted in
@@ -177,32 +178,49 @@ private fun EmbeddedNappletTab(
}
Scaffold(
topBar = {
EmbeddedTabTopBar(
title = title,
leading = {
// The shield is the sandbox-access affordance here (capabilities + network).
IconButton(onClick = { showAccess = true }) {
Icon(MaterialSymbols.Security, contentDescription = stringResource(R.string.favorite_app_access_show))
}
},
onReload = { controller.reload() },
onPopOut = {
FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
},
)
},
bottomBar = {
AppBottomBar(Route.FavoriteNostrApp(coordinate), nav, accountViewModel) { route -> nav.navBottomBar(route) }
},
) { 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()) },
)
.padding(padding),
) {
// Reserve the content area; the warm surface is positioned over these bounds by the tab
// layer. Inset the top by the puck's height so the surface (drawn over these bounds, above
// the nav tree) doesn't cover the floating puck.
Box(
Modifier
.fillMaxSize()
.padding(top = AppControlPuckReserve)
.onGloballyPositioned { EmbeddedTabHost.reportBounds(it.boundsInWindow()) },
)
// Floating controls instead of a top bar — the napplet already titles itself. The shield is
// the always-visible trusted sandbox marker (the page can't draw over it); tap to reveal the
// "what it can access" sheet, reload, and pop-out.
AppControlPuck(
trustedIcon = MaterialSymbols.Security,
trustedTint = MaterialTheme.colorScheme.primary,
trustedDescription = stringResource(R.string.favorite_app_access_show),
modifier =
Modifier
.align(Alignment.TopEnd)
.padding(8.dp),
) {
IconButton(onClick = { controller.reload() }) {
Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload))
}
IconButton(onClick = { showAccess = true }) {
Icon(MaterialSymbols.Info, contentDescription = stringResource(R.string.favorite_app_access_show))
}
IconButton(onClick = {
FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()))
}) {
Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window))
}
}
}
}
}
@@ -248,14 +248,21 @@ class NappletHostActivity : ComponentActivity() {
// Route the back gesture into the WebView's history first (see backCallback).
onBackPressedDispatcher.addCallback(this, backCallback)
// Persistent trusted chrome (anti-phishing bar the applet can't draw over) over a content
// frame that shows a loading screen → the applet's WebView, or an "unavailable" screen.
// The applet titles itself, so instead of a full-width bar we float a single trusted control
// chip (the anti-phishing shield the applet can't draw over) over the content frame, which shows
// a loading screen → the applet's WebView, or an "unavailable" screen.
val root =
LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(buildSandboxBar())
addView(buildDivider())
addView(contentFrame, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f))
FrameLayout(this).apply {
addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT))
addView(
buildFloatingChip(),
FrameLayout
.LayoutParams(
FrameLayout.LayoutParams.WRAP_CONTENT,
FrameLayout.LayoutParams.WRAP_CONTENT,
Gravity.TOP or Gravity.END,
).apply { setMargins(dp(8), dp(8), dp(8), dp(8)) },
)
}
setContentView(root)
// Activities are edge-to-edge by default on recent Android; pad by the system bar and
@@ -702,46 +709,33 @@ class NappletHostActivity : ComponentActivity() {
private fun barTitle(): String = title.ifBlank { getString(R.string.napplet_untitled) }
/** The always-visible bar: a shield, the napplet's name, and an info affordance to see its access. */
private fun buildSandboxBar(): View {
/**
* The floating trusted control chip: the sandbox **shield** is always visible (the anti-phishing
* marker the applet can't draw over); tapping it reveals the actions — the nSite network/Tor toggle
* (website mode only), reload, and the "what it can access" sheet. Replaces the old full-width bar,
* since the applet already titles itself.
*/
private fun buildFloatingChip(): View {
val onSurface = resolveThemeColor(android.R.attr.textColorPrimary)
val bar =
val dimmed = resolveThemeColor(android.R.attr.textColorSecondary)
// Hidden until the shield is tapped.
val actions =
LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground))
setPadding(dp(14), dp(10), dp(14), dp(10))
isClickable = true
setOnClickListener { showAccessDialog() }
contentDescription = getString(R.string.napplet_chrome_permissions_desc)
visibility = View.GONE
}
bar.addView(
TextView(this).apply {
text = "🛡" // shield
setPadding(0, 0, dp(10), 0)
},
)
bar.addView(
TextView(this).apply {
text = barTitle()
setTextColor(onSurface)
textSize = 16f
maxLines = 1
ellipsize = android.text.TextUtils.TruncateAt.END
layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)
},
)
// nSite network indicator + toggle: the Tor logo, lit when routing through Tor and dimmed when
// the site loads over the open web. Only shown for a website-mode nSite when Tor is active —
// otherwise there is nothing to route through. Its own click opens the network dialog (and, by
// consuming the click, doesn't also fire the access sheet bound to the bar).
// nSite network indicator + toggle: the Tor logo, lit over Tor and dimmed over the open web.
// Only for a website-mode nSite when Tor is active — otherwise there is nothing to route through.
if (websiteMode && proxyPort > 0) {
bar.addView(
actions.addView(
ImageView(this).apply {
setImageResource(R.drawable.ic_tor)
setColorFilter(if (useTor) onSurface else resolveThemeColor(android.R.attr.textColorSecondary))
setColorFilter(if (useTor) onSurface else dimmed)
alpha = if (useTor) 1f else 0.4f
setPadding(0, 0, dp(12), 0)
setPadding(dp(8), 0, dp(8), 0)
isClickable = true
setOnClickListener { showNetworkDialog() }
contentDescription = getString(if (useTor) R.string.napplet_net_tor_desc else R.string.napplet_net_open_desc)
@@ -749,16 +743,53 @@ class NappletHostActivity : ComponentActivity() {
},
)
}
bar.addView(
actions.addView(chipGlyph("", onSurface, getString(R.string.napplet_chrome_reload)) { if (this::webView.isInitialized) webView.reload() })
actions.addView(chipGlyph("", onSurface, getString(R.string.napplet_chrome_permissions_desc)) { showAccessDialog() })
val shield =
TextView(this).apply {
text = "" // circled info
setTextColor(onSurface)
textSize = 18f
},
)
return bar
text = "🛡"
textSize = 16f
setPadding(dp(4), dp(2), dp(4), dp(2))
isClickable = true
contentDescription = getString(R.string.napplet_chrome_permissions_desc)
setOnClickListener {
actions.visibility = if (actions.visibility == View.GONE) View.VISIBLE else View.GONE
}
}
return LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(8), dp(6), dp(8), dp(6))
elevation = dp(4).toFloat()
background =
android.graphics.drawable.GradientDrawable().apply {
cornerRadius = dp(24).toFloat()
setColor(resolveThemeColor(android.R.attr.colorBackground))
}
addView(shield)
addView(actions)
}
}
/** A single tappable glyph button for the floating chip (reload, info). */
private fun chipGlyph(
glyph: String,
color: Int,
desc: String,
onClick: () -> Unit,
): TextView =
TextView(this).apply {
text = glyph
setTextColor(color)
textSize = 18f
setPadding(dp(8), 0, dp(8), 0)
isClickable = true
contentDescription = desc
setOnClickListener { onClick() }
}
/**
* Explains the site's current network routing and offers to switch it. Switching persists the
* per-site choice (via the broker, which owns the preference) and relaunches this screen so the new
@@ -793,12 +824,6 @@ class NappletHostActivity : ComponentActivity() {
recreate()
}
private fun buildDivider(): View =
View(this).apply {
layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, dp(1))
setBackgroundColor(resolveThemeColor(android.R.attr.textColorPrimary) and 0x22FFFFFF)
}
/** Lists, in plain language, exactly which capabilities this napplet was launched with. */
private fun showAccessDialog() {
val body =
@@ -10,6 +10,7 @@
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_chrome_reload">Reload</string>
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>