diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md index f87c24a31b..2797131d3d 100644 --- a/amethyst/plans/2026-09-26-browser-ui-review.md +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -249,8 +249,13 @@ is gone. `EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only they used. -Still open: -- The sandboxed apps' "What it can access" is still a platform `AlertDialog`. It lists launch - capabilities, not browser state. -- The embedded nsite/napplet tab has no find or text-size plumbing yet (`hasFind` / `hasTextSize` are - off there). +**Sandboxed apps, embedded and full screen alike:** +- An embedded nSite is labelled as an nSite, not "Sandboxed app", and gets the Tor row. Switching the + route saves it in `NappletNetworkRegistry` and rebuilds the session, as the full-screen host relaunches. +- "What it can access" is `AccessInfoSheet`: the launch capabilities, the keys-stay-in-Amethyst row, the + route (nSites), and Manage permissions. It replaces both the platform `AlertDialog` and the embedded + tab's `AccessDialog`. +- The embedded tab has find, text size and the console. `NappletEmbedContract` gained `MSG_FIND`, + `MSG_FIND_NEXT`, `MSG_FIND_RESULT`, `MSG_SET_TEXT_ZOOM` and `MSG_CONSOLE_LOG`, which + `NappletHostService` serves the same way `NappletBrowserService` does. Load and HTTP errors show up as + console errors, as they do full screen. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 4cd48b1181..8df5a2000f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -525,13 +526,3 @@ class EmbeddedWebAppController( private const val MAX_CONSOLE_LOGS = 200 } } - -/** Maps the provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ -private fun consoleLevelOf(level: String): ConsoleLine.Level = - when (level) { - "ERROR" -> ConsoleLine.Level.ERROR - "WARNING" -> ConsoleLine.Level.WARNING - "DEBUG" -> ConsoleLine.Level.DEBUG - "TIP" -> ConsoleLine.Level.INFO - else -> ConsoleLine.Level.LOG - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt index 84e3bb70be..faa8cf7204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt @@ -33,3 +33,13 @@ interface ConsoleBridge { fun clearConsoleLogs() } + +/** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ +fun consoleLevelOf(level: String): ConsoleLine.Level = + when (level) { + "ERROR" -> ConsoleLine.Level.ERROR + "WARNING" -> ConsoleLine.Level.WARNING + "DEBUG" -> ConsoleLine.Level.DEBUG + "TIP" -> ConsoleLine.Level.INFO + else -> ConsoleLine.Level.LOG + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 39d3fb0f6b..79a7ed1dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -35,19 +35,27 @@ import android.os.Message import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -67,7 +75,9 @@ class EmbeddedNostrAppController( private val params: Bundle, ) : EmbeddedSurfaceController, EmbeddedImeBridge, - EmbeddedMagnifierProbe { + EmbeddedMagnifierProbe, + ConsoleBridge, + FindBridge { private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage)) private var serviceMessenger: Messenger? = null private var bound = false @@ -112,6 +122,14 @@ class EmbeddedNostrAppController( override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null + /** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */ + override val consoleLogs = mutableStateListOf() + + override fun clearConsoleLogs() = consoleLogs.clear() + + private val _findResult = mutableStateOf(null) + override val findResult: State = _findResult + private val connection = object : ServiceConnection { override fun onServiceConnected( @@ -266,6 +284,22 @@ class EmbeddedNostrAppController( } } } + NappletEmbedContract.MSG_FIND_RESULT -> { + val data = msg.data ?: return true + _findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL)) + } + NappletEmbedContract.MSG_CONSOLE_LOG -> { + val data = msg.data ?: return true + if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) + consoleLogs.add( + ConsoleLine( + consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()), + data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(), + data.getString(NappletEmbedContract.KEY_CONSOLE_SOURCE).orEmpty(), + data.getInt(NappletEmbedContract.KEY_CONSOLE_LINE, 0), + ), + ) + } NappletEmbedContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true @@ -305,6 +339,15 @@ class EmbeddedNostrAppController( fun reload() = send(NappletEmbedContract.MSG_RELOAD) + override fun find(query: String) { + if (query.isEmpty()) _findResult.value = null + send(NappletEmbedContract.MSG_FIND) { putString(NappletEmbedContract.KEY_FIND_QUERY, query) } + } + + override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) } + + fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) } + /** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */ override fun retry() { hasLoadedReal = false @@ -353,5 +396,7 @@ class EmbeddedNostrAppController( private companion object { private val SESSION_SEQ = AtomicLong() + + private const val MAX_CONSOLE_LOGS = 200 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index a3b12d3d61..3a7012cdcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -26,18 +26,19 @@ import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,11 +51,14 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -62,10 +66,6 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_unsupported -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_static -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_title -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_open -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_tor import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable import com.vitorpamplona.amethyst.commons.resources.favorite_apps @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -133,7 +134,10 @@ private fun EmbeddedNostrAppTab( // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). - val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } + // Bumped when the user re-routes an nSite (Tor ↔ open web): the session is rebuilt with the new route, + // as the full-screen host relaunches itself. + var networkEpoch by remember(coordinate) { mutableIntStateOf(0) } + val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch, networkEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -145,16 +149,19 @@ private fun EmbeddedNostrAppTab( val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty() val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE)) val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) + // Only nSites have a route of their own to choose, and only when Tor is running. + val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null val scope = rememberCoroutineScope() var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } + var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id, EmbeddedTabHost.rebuildEpoch) { + remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } @@ -174,30 +181,39 @@ private fun EmbeddedNostrAppTab( // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate, isFavorite, controller) { + remember(title, coordinate, isFavorite, torOn, textZoom, controller) { EmbeddedTabChrome( ui = BrowserPillUi( title = title.ifBlank { coordinate }, chrome = BrowserChrome.State( - surface = BrowserChrome.Surface.NAPPLET, + surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, presentation = BrowserChrome.Presentation.EMBEDDED, url = "", startUrl = "", + torOn = torOn, hasAccessInfo = true, - // The embedded nsite/napplet host has no find or text-size plumbing (yet). - hasFind = false, - hasTextSize = false, ), isFavorite = isFavorite, + textZoom = textZoom, ), onEvent = { event -> + if (event is BrowserPillEvent.TextZoom) { + textZoom = event.percent + controller.setTextZoom(event.percent) + } when ((event as? BrowserPillEvent.Action)?.action) { BrowserChrome.Action.RELOAD -> controller.reload() BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr) BrowserChrome.Action.ACCESS_INFO -> showAccess = true + BrowserChrome.Action.TOR -> { + // Persist the new route, then rebuild the session so it loads that way. + NappletNetworkRegistry.set(permissionCoordinate, !useTor) + EmbeddedTabHost.evict(id) + networkEpoch++ + } BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) BrowserChrome.Action.FAVORITE -> { val favId = "nostr:$coordinate" @@ -247,7 +263,22 @@ private fun EmbeddedNostrAppTab( BackHandler(enabled = canGoBack) { controller.back() } if (showAccess) { - AccessDialog(title, capLabels, profile.exposesNetwork, useTor) { showAccess = false } + Dialog(onDismissRequest = { showAccess = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = title.ifBlank { coordinate }, + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capLabels, + torOn = torOn, + onManagePermissions = { + showAccess = false + nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) + }, + onDone = { showAccess = false }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } } Scaffold( @@ -295,36 +326,6 @@ private fun UnavailableTab( } } -@Composable -private fun AccessDialog( - title: String, - capLabels: List, - showsNetwork: Boolean, - useTor: Boolean, - onDismiss: () -> Unit, -) { - val capsBody = - if (capLabels.isEmpty()) { - stringRes(Res.string.favorite_app_access_static) - } else { - capLabels.joinToString("\n") { "• $it" } - } - val networkBody = - if (showsNetwork) { - "\n\n" + stringRes(if (useTor) Res.string.favorite_app_network_tor else Res.string.favorite_app_network_open) - } else { - "" - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(if (title.isBlank()) stringRes(Res.string.favorite_app_access_title) else title) }, - text = { Text(capsBody + networkBody) }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringRes(android.R.string.ok)) } - }, - ) -} - private fun noticeResId(notice: String): StringResource? = when (notice) { NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d1e8fac7c4..49b19ed171 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5389,6 +5389,12 @@ Nothing allowed yet What it can access Your keys never leave Amethyst + Every sign, publish, upload or payment goes through your approval + No special access + It runs sandboxed and can't act on your account + Manage permissions + nSite · sandboxed + nApplet · sandboxed Console Secure connection Not secure diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt new file mode 100644 index 0000000000..e0bf7fdd64 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt @@ -0,0 +1,164 @@ +/* + * 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.commons.browser.ui.pill + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_keys_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_manage +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_napplet +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_nsite +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * "What it can access" for a sandboxed nSite or nApplet: the capabilities it was launched with, how it + * reaches the network (nSites only), and the promise that the keys stay in Amethyst. The sandboxed + * counterpart of [PageInfoSheet], drawn with the same header and grouped cards. + * + * [capabilities] are already-localized labels; empty means a static site with no special access. + * [torOn] is null when the app has no network route of its own to show. + */ +@Composable +fun AccessInfoSheet( + title: String, + isWebsite: Boolean, + capabilities: List, + torOn: Boolean?, + onManagePermissions: (() -> Unit)?, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(PillDefaults.SheetPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(title, size = 48.dp) + Spacer(Modifier.width(14.dp)) + Column { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically) { + SecurityIcon(BrowserChrome.Security.SANDBOX, size = 14.dp) + Spacer(Modifier.width(4.dp)) + Text( + stringRes(if (isWebsite) Res.string.browser_pill_access_nsite else Res.string.browser_pill_access_napplet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_access)) { + if (capabilities.isEmpty()) { + GroupRow( + icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_none), + supporting = stringRes(Res.string.browser_pill_access_none_desc), + onClick = null, + ) + } else { + capabilities.forEachIndexed { index, label -> + if (index > 0) GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) }, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + title = label, + supporting = null, + onClick = null, + ) + } + } + GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_desc), + supporting = stringRes(Res.string.browser_pill_access_keys_desc), + onClick = null, + ) + } + + torOn?.let { tor -> + GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) { + GroupRow( + icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open), + supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = null, + ) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + if (onManagePermissions != null) { + TextButton(onClick = onManagePermissions) { Text(stringRes(Res.string.browser_pill_access_manage)) } + Spacer(Modifier.width(8.dp)) + } + Button(onClick = onDone) { Text(stringRes(Res.string.browser_pill_ok)) } + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt index ace3a1cf43..1a2531d2c4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -238,3 +238,20 @@ fun PageInfoPreview() { } } } + +@Preview +@Composable +fun AccessInfoPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp).width(380.dp)) { + AccessInfoSheet( + title = "Habla", + isWebsite = true, + capabilities = listOf("Sign events as you", "Publish to your relays", "Upload files"), + torOn = true, + onManagePermissions = {}, + onDone = {}, + ) + } + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt index 3ac0d504e9..08711fed43 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -89,6 +89,8 @@ class BrowserPillRenderTest { @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() } + @Test fun accessInfo() = render("12-access-info", 820, 610) { AccessInfoPreview() } + private companion object { const val SETTLE_FRAMES = 12 const val FRAME_MILLIS = 60L diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index 5041339b99..a781eb4722 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill @@ -101,6 +102,15 @@ class BrowserChromeHost( fun onPanelsChanged() {} } + /** What a sandboxed app was launched with, for [showAccessInfo]. */ + class AccessInfo( + val title: String, + val isWebsite: Boolean, + val capabilities: List, + val torOn: Boolean?, + val onManagePermissions: (() -> Unit)?, + ) + /** A page's JS dialog waiting for an answer. */ class PendingDialog( val type: PageDialogType, @@ -137,6 +147,7 @@ class BrowserChromeHost( var dialog by mutableStateOf(null) var permissionPrompt by mutableStateOf(null) private var pageInfoOpen by mutableStateOf(false) + private var accessInfo by mutableStateOf(null) private var certificate by mutableStateOf(null) private var topView: ComposeView? = null @@ -203,6 +214,11 @@ class BrowserChromeHost( console.add(line) } + /** "What it can access" for a sandboxed nSite or nApplet. */ + fun showAccessInfo(info: AccessInfo) { + accessInfo = info + } + /** Page info for the page on screen, with its certificate when it has one. */ fun showPageInfo(certificate: CertificateInfo?) { this.certificate = certificate @@ -329,6 +345,27 @@ class BrowserChromeHost( ) } } + accessInfo?.let { info -> + Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = info.title, + isWebsite = info.isWebsite, + capabilities = info.capabilities, + torOn = info.torOn, + onManagePermissions = + info.onManagePermissions?.let { manage -> + { + accessInfo = null + manage() + } + }, + onDone = { accessInfo = null }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } + } if (pageInfoOpen) { Dialog(onDismissRequest = { pageInfoOpen = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 1f977a02d3..38df1171f7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -115,6 +115,34 @@ object NappletEmbedContract { */ const val MSG_FILE_CHOOSER_RESULT = 19 + /** Client → provider: find [KEY_FIND_QUERY] in the page; an empty query clears the highlights. */ + const val MSG_FIND = 20 + + /** Client → provider: move to the next ([KEY_FIND_FORWARD] true) or previous match. */ + const val MSG_FIND_NEXT = 21 + + /** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */ + const val MSG_FIND_RESULT = 22 + + /** Client → provider: set the page's text size to [KEY_TEXT_ZOOM] percent. */ + const val MSG_SET_TEXT_ZOOM = 23 + + /** + * Provider → client: one line for the developer console — [KEY_CONSOLE_LEVEL] (WebView's + * `ConsoleMessage.MessageLevel` name), [KEY_CONSOLE_MESSAGE], [KEY_CONSOLE_SOURCE], [KEY_CONSOLE_LINE]. + */ + const val MSG_CONSOLE_LOG = 24 + + const val KEY_FIND_QUERY = "findQuery" + const val KEY_FIND_FORWARD = "findForward" + const val KEY_FIND_ACTIVE = "findActive" + const val KEY_FIND_TOTAL = "findTotal" + const val KEY_TEXT_ZOOM = "textZoom" + const val KEY_CONSOLE_LEVEL = "consoleLevel" + const val KEY_CONSOLE_MESSAGE = "consoleMessage" + const val KEY_CONSOLE_SOURCE = "consoleSource" + const val KEY_CONSOLE_LINE = "consoleLine" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" 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 b16ab84463..e14cc25611 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.napplethost import android.annotation.SuppressLint -import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection @@ -213,9 +212,9 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (chrome?.handleBack() == true) { - Unit - } else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { + // The chrome first (open pill, find), then the applet's own history. + if (chrome?.handleBack() == true) return + if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { webView.goBack() } else { isEnabled = false @@ -1047,18 +1046,15 @@ class NappletHostActivity : ComponentActivity() { /** Lists, in plain language, exactly which capabilities this napplet was launched with. */ private fun showAccessDialog() { - val body = - if (capabilityLabels.isEmpty()) { - getString(R.string.napplet_chrome_static_site) - } else { - capabilityLabels.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe) - } - AlertDialog - .Builder(this) - .setTitle(getString(R.string.napplet_chrome_access_title, barTitle())) - .setMessage(body) - .setPositiveButton(android.R.string.ok, null) - .show() + chrome?.showAccessInfo( + BrowserChromeHost.AccessInfo( + title = barTitle(), + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capabilityLabels, + torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, + onManagePermissions = if (brokerMessenger != null) ::openPermissions else null, + ), + ) } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index b3a69b8d13..998117aad8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -38,6 +38,7 @@ import android.os.Messenger import android.os.SystemClock import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage import android.webkit.JsPromptResult import android.webkit.JsResult import android.webkit.RenderProcessGoneDetail @@ -60,6 +61,7 @@ import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.booleanOrNull import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -129,6 +131,9 @@ class NappletHostService : Service() { // Last main-frame error state, pushed to the client so it can show an error/retry overlay over the // surface (the embedded surface has no error page of its own). var loadFailed = false + + // The user's text size, re-applied when a renderer crash forces a fresh WebView. + var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -213,6 +218,17 @@ class NappletHostService : Service() { tab.bridgeReplyProxy?.postMessage(payload) } NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg) + NappletEmbedContract.MSG_FIND -> { + val wv = tabFor(msg)?.webView ?: return true + val query = msg.data?.getString(NappletEmbedContract.KEY_FIND_QUERY).orEmpty() + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } + NappletEmbedContract.MSG_FIND_NEXT -> tabFor(msg)?.webView?.findNext(msg.data?.getBoolean(NappletEmbedContract.KEY_FIND_FORWARD, true) ?: true) + NappletEmbedContract.MSG_SET_TEXT_ZOOM -> { + val tab = tabFor(msg) ?: return true + tab.textZoom = msg.data?.getInt(NappletEmbedContract.KEY_TEXT_ZOOM, tab.textZoom) ?: tab.textZoom + tab.webView?.let { BrowserWebTools.setTextZoom(it, tab.textZoom) } + } NappletEmbedContract.MSG_FILE_CHOOSER_RESULT -> { val tab = tabFor(msg) ?: return true val data = msg.data ?: return true @@ -362,6 +378,8 @@ class NappletHostService : Service() { wv.dropSystemBarInsets() if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage) + wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } + if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom) tab.webView = wv wv.loadUrl(NappletWebContract.SHELL_URL) return wv @@ -426,6 +444,11 @@ class NappletHostService : Service() { fileChooserParams: FileChooserParams, ): Boolean = requestFileChooser(tab, filePathCallback, fileChooserParams) + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + pushConsoleLog(tab, consoleMessage.messageLevel().name, consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + return true + } + // Setting a chrome client at all is what opts this WebView into the default JS-dialog handling, // and this one is built from a Service context — there is no window token to attach a dialog to, // and an applet's alert() must not be able to draw over the main app's trusted chrome anyway. @@ -547,6 +570,7 @@ class NappletHostService : Service() { request: WebResourceRequest, error: WebResourceError, ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()), request.url?.toString().orEmpty(), 0) // Only a main-frame failure blanks the applet; a missing sub-resource is irrelevant to whether // it opened. if (!request.isForMainFrame) return @@ -554,6 +578,14 @@ class NappletHostService : Service() { pushLoadState(tab, isLoading = false) } + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()), request.url?.toString().orEmpty(), 0) + } + /** * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the * whole process — every other tab included. Drop just this tab's WebView and report the load as @@ -599,6 +631,42 @@ class NappletHostService : Service() { runCatching { tab.clientMessenger?.send(message) } } + private fun pushFindResult( + tab: NappletTab, + active: Int, + total: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_FIND_RESULT).apply { + data = + Bundle().apply { + putInt(NappletEmbedContract.KEY_FIND_ACTIVE, active) + putInt(NappletEmbedContract.KEY_FIND_TOTAL, total) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + + private fun pushConsoleLog( + tab: NappletTab, + level: String, + text: String, + source: String, + line: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_CONSOLE_LOG).apply { + data = + Bundle().apply { + putString(NappletEmbedContract.KEY_CONSOLE_LEVEL, level) + putString(NappletEmbedContract.KEY_CONSOLE_MESSAGE, text) + putString(NappletEmbedContract.KEY_CONSOLE_SOURCE, source) + putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ private fun pushLoadState( tab: NappletTab, diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 2ac71b58ff..d55e67ec81 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -4,30 +4,11 @@ Invalid nApplet. This device\'s WebView is too old to run nApplets safely. - - - What “%1$s” can access - 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 - Manage permissions - Reload - + “%1$s” published a note as you “%1$s” uploaded a file “%1$s” made a payment - - This site loads over Tor. Tap to change. - This site loads over the open web. Tap to change. - - Loads over Tor - Loads over the open web - - - Add to favorites - Remove from favorites - Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again.