mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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.
|
||||
|
||||
+1
-10
@@ -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
|
||||
}
|
||||
|
||||
+10
@@ -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
|
||||
}
|
||||
|
||||
+46
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+45
-44
@@ -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>
|
||||
|
||||
+164
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+37
@@ -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) {
|
||||
|
||||
+28
@@ -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"
|
||||
|
||||
+12
-16
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+68
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user