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 60ed1830db..f4aef22c24 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 @@ -25,14 +25,18 @@ import android.os.Build import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.IconButton +import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect @@ -46,11 +50,9 @@ 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 +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.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.bottombars.favoriteIds @@ -58,13 +60,14 @@ 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 +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabTopBar /** * 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. + * layer, which keeps the session warm across tab swaps. This screen owns only the chrome — the shared + * [EmbeddedTabTopBar] (sandbox shield + reload + pop-out), identical to the nsite/napplet tab — plus a + * security & privacy sheet where Tor lives. 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. @@ -88,7 +91,6 @@ fun FavoriteWebAppScreen( } @RequiresApi(Build.VERSION_CODES.R) -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun EmbeddedFavoriteTab( url: String, @@ -101,6 +103,7 @@ private fun EmbeddedFavoriteTab( var currentUrl by remember { mutableStateOf(url) } var canGoBack by remember { mutableStateOf(false) } + var showSecurity by remember { mutableStateOf(false) } val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } var torOn by remember { mutableStateOf(proxyAvailable) } @@ -133,32 +136,26 @@ private fun EmbeddedFavoriteTab( BackHandler(enabled = canGoBack) { controller.back() } + if (showSecurity) { + WebAppSecurityDialog( + host = hostLabel(currentUrl), + proxyAvailable = proxyAvailable, + torOn = torOn, + onToggleTor = { + torOn = !torOn + controller.setTor(torOn) + }, + onDismiss = { showSecurity = false }, + ) + } + Scaffold( topBar = { - TopAppBar( - title = { - Text(text = hostLabel(currentUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - actions = { - if (proxyAvailable) { - IconButton(onClick = { - torOn = !torOn - controller.setTor(torOn) - }) { - Icon( - MaterialSymbols.Security, - contentDescription = stringResource(if (torOn) R.string.browser_tor_on else R.string.browser_tor_off), - tint = if (torOn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - 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)) - } - }, + EmbeddedTabTopBar( + title = hostLabel(currentUrl), + onSecurity = { showSecurity = true }, + onReload = { controller.reload() }, + onPopOut = { FavoriteAppLauncher.launchUrl(context, url) }, ) }, bottomBar = { @@ -175,5 +172,34 @@ private fun EmbeddedFavoriteTab( } } +@Composable +private fun WebAppSecurityDialog( + host: String, + proxyAvailable: Boolean, + torOn: Boolean, + onToggleTor: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(host) }, + text = { + Column { + Text(stringResource(R.string.favorite_web_security_body)) + if (proxyAvailable) { + Spacer(Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text(stringResource(R.string.favorite_web_tor), modifier = Modifier.weight(1f)) + Switch(checked = torOn, onCheckedChange = { onToggleTor() }) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) } + }, + ) +} + /** The host of [url] for the tab title, falling back to the raw string. */ internal fun hostLabel(url: String): String = runCatching { Uri.parse(url).host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url 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 new file mode 100644 index 0000000000..ebf3423fe8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabTopBar.kt @@ -0,0 +1,69 @@ +/* + * 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 + * exact same bar so they read as one consistent surface: a **sandbox shield** (security & privacy: what + * the app can access, and its network/Tor routing), the title, **reload**, and **open-in-own-window**. + * + * Tor deliberately lives *inside* the shield's sheet rather than as its own top-bar icon, so the shield + * is never confused with a Tor toggle (the onion `ic_tor` is the Tor mark elsewhere; the shield is the + * sandbox/security mark). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmbeddedTabTopBar( + title: String, + onSecurity: () -> Unit, + onReload: () -> Unit, + onPopOut: () -> Unit, +) { + TopAppBar( + navigationIcon = { + IconButton(onClick = onSecurity) { + Icon(MaterialSymbols.Security, contentDescription = stringResource(R.string.embedded_tab_security)) + } + }, + 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 e3f04cbdef..b5cf17d82c 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 @@ -29,7 +29,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.AlertDialog 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 @@ -49,15 +48,12 @@ 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.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp -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.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -67,6 +63,7 @@ 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 +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 @@ -173,25 +170,12 @@ private fun EmbeddedNappletTab( Scaffold( topBar = { - TopAppBar( - navigationIcon = { - // The sandbox shield is part of the trusted chrome; tap it (or the title) to see access. - IconButton(onClick = { showAccess = true }) { - Icon(MaterialSymbols.Security, contentDescription = stringResource(R.string.favorite_app_access_show)) - } - }, - title = { - Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) - }, - actions = { - IconButton(onClick = { controller.reload() }) { - Icon(MaterialSymbols.Refresh, contentDescription = stringResource(R.string.browser_reload)) - } - IconButton(onClick = { - FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) - }) { - Icon(MaterialSymbols.AutoMirrored.OpenInNew, contentDescription = stringResource(R.string.favorite_app_open_window)) - } + EmbeddedTabTopBar( + title = title, + onSecurity = { showAccess = true }, + onReload = { controller.reload() }, + onPopOut = { + FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) }, ) }, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97925adaec..ca9a8ae2c8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -681,6 +681,9 @@ What this app can access Runs sandboxed with no special access to your account. What it can access + Security & privacy + This site runs sandboxed in a keyless process and signs in per site (NIP-07). + Load over Tor Loads over Tor. Loads over the open web. This app isn\'t loaded yet. Open it from its card, or try again in a moment.