mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-30 19:46:17 +00:00
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:
Binary file not shown.
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
-41
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
+52
-25
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+101
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-65
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+38
-20
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-51
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user