feat(browser): bring embedded nSites and nApplets to parity with full screen

- An embedded nSite is shown as an nSite with the Tor row. Switching the route
  saves it and rebuilds the session, as the full-screen host relaunches.
- "What it can access" is a new AccessInfoSheet card in both the embedded tab
  and the full-screen window, replacing the platform dialogs.
- The embedded tab gets find in page, text size and the developer console
  through new NappletEmbedContract messages served by NappletHostService.

Also removes nappletHost strings left unused by the Compose chrome.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ
This commit is contained in:
Claude
2026-09-26 22:52:20 +00:00
parent d52d54ac3f
commit b1849e5880
14 changed files with 447 additions and 96 deletions
+10 -5
View File
@@ -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.
@@ -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
}
@@ -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
}
@@ -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<ConsoleLine>()
override fun clearConsoleLogs() = consoleLogs.clear()
private val _findResult = mutableStateOf<FindResult?>(null)
override val findResult: State<FindResult?> = _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
}
}
@@ -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<String>,
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
@@ -5389,6 +5389,12 @@
<string name="browser_pill_site_settings_none">Nothing allowed yet</string>
<string name="browser_pill_access">What it can access</string>
<string name="browser_pill_access_desc">Your keys never leave Amethyst</string>
<string name="browser_pill_access_keys_desc">Every sign, publish, upload or payment goes through your approval</string>
<string name="browser_pill_access_none">No special access</string>
<string name="browser_pill_access_none_desc">It runs sandboxed and can't act on your account</string>
<string name="browser_pill_access_manage">Manage permissions</string>
<string name="browser_pill_access_nsite">nSite · sandboxed</string>
<string name="browser_pill_access_napplet">nApplet · sandboxed</string>
<string name="browser_pill_console">Console</string>
<string name="browser_pill_security_https">Secure connection</string>
<string name="browser_pill_security_http">Not secure</string>
@@ -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<String>,
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)) }
}
}
}
}
@@ -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 = {},
)
}
}
}
@@ -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
@@ -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<String>,
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<PendingDialog?>(null)
var permissionPrompt by mutableStateOf<PendingPermission?>(null)
private var pageInfoOpen by mutableStateOf(false)
private var accessInfo by mutableStateOf<AccessInfo?>(null)
private var certificate by mutableStateOf<CertificateInfo?>(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) {
@@ -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"
@@ -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,
),
)
}
/**
@@ -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,
+1 -20
View File
@@ -4,30 +4,11 @@
<string name="napplet_invalid">Invalid nApplet.</string>
<string name="napplet_webview_too_old">This device\'s WebView is too old to run nApplets safely.</string>
<!-- Trusted chrome (top bar + live action notices) -->
<string name="napplet_chrome_access_title">What “%1$s” can access</string>
<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_manage_permissions">Manage permissions</string>
<string name="napplet_chrome_reload">Reload</string>
<!-- Browser address bar and developer console strings are in :commons -->
<!-- Live "allow always" action notices -->
<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>
<!-- nSite network routing (Tor vs open web) -->
<string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string>
<string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string>
<!-- Short labels for the pull-down sheet's network row -->
<string name="napplet_net_tor_label">Loads over Tor</string>
<string name="napplet_net_open_label">Loads over the open web</string>
<!-- Favorite toggle in the pull-down sheet -->
<string name="browser_favorite_add">Add to favorites</string>
<string name="browser_favorite_remove">Remove from favorites</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>