From cb8bbad38d5b11016276acbbaef44029fea6709e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 21:29:15 +0000 Subject: [PATCH] feat: floating control puck for running app surfaces + remember Tor per web client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN --- .../com/vitorpamplona/amethyst/Amethyst.kt | Bin 7407 -> 7702 bytes .../amethyst/napplet/WebUrlNetworkRegistry.kt | 91 +++++++++++++ .../loggedIn/browser/BrowserHostActivity.kt | 84 ++++++------ .../browser/EmbeddedBrowserSurface.kt | 5 +- .../loggedIn/browser/FavoriteWebAppScreen.kt | 77 +++++++---- .../screen/loggedIn/embed/AppControlPuck.kt | 101 ++++++++++++++ .../loggedIn/embed/EmbeddedTabTopBar.kt | 65 --------- .../favorites/FavoriteNappletScreen.kt | 58 +++++--- .../napplethost/NappletHostActivity.kt | 127 +++++++++++------- nappletHost/src/main/res/values/strings.xml | 1 + 10 files changed, 406 insertions(+), 203 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebUrlNetworkRegistry.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/AppControlPuck.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabTopBar.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index 51aba7e3515e90a2f6813d4ca8eb11134c924877..350f8a525914387c2562cf49041704daff775305 100644 GIT binary patch delta 232 zcmY+9F>V4u3`Hv%f)GubRKJ7*s3Xy)O_hjxSpH@wOvWSI(Wax~1W24CQVzxy*hL5_ zE?oWpo_&5kt$tqyk2#c*=!-Qs@0woet?2IWX>fQrjwhpiq0U=w=2eQ!jyF-Le0$6v zD9W13T+v{H0EO_tQ?zrS%bJY9}^7V0RUfr2iyPv diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebUrlNetworkRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebUrlNetworkRegistry.kt new file mode 100644 index 0000000000..e43fad3268 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebUrlNetworkRegistry.kt @@ -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() + 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 } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserHostActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserHostActivity.kt index 0eed6032fb..5bee58d941 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserHostActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserHostActivity.kt @@ -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)) + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserSurface.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserSurface.kt index c1f0d855c4..2b5ddb080f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserSurface.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedBrowserSurface.kt @@ -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. 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 4a686d7d77..fee1e79ddc 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 @@ -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)) + } + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/AppControlPuck.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/AppControlPuck.kt new file mode 100644 index 0000000000..7efd7d6270 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/AppControlPuck.kt @@ -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) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabTopBar.kt deleted file mode 100644 index 818f91a3ee..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabTopBar.kt +++ /dev/null @@ -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)) - } - }, - ) -} 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 faf71ebacf..c850e24971 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 @@ -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)) + } + } + } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index a1d4e04455..d259bb6a0a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -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 = diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index fb51769ca8..30e17bda6b 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -10,6 +10,7 @@ It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets. Static site — it has no special access to your account. What this app can access + Reload “%1$s” published a note as you “%1$s” uploaded a file “%1$s” made a payment