From d52d54ac3f1f74c95c99f761fa1cad7c2d19680a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 18:36:00 +0000 Subject: [PATCH] feat(browser): draw every browser surface with the Compose pill components Embedded tabs, the full-screen web browser and the nsite/napplet windows now share one set of Compose components (commonsUI browser/ui/pill): - Embedded tabs: EmbeddedTabChrome carries a BrowserPillUi plus one event callback. The tab layer draws BrowserPill, FindInPagePill and ConsoleSheet. The address editor suggests favorites and history. - Embedded page dialogs: PageDialogCard, PermissionPromptCard (now with "Only this time") and PageInfoSheet. Page info reports the certificate as fields, and site permissions can be edited in place. - Full-screen windows: BrowserChromeHost hosts the same composables in ComposeViews over the WebView, replacing the hand-built View chrome. - Clear site data asks for confirmation first. Removed the old renderers: NappletControlSheet, BrowserFindBar, NappletConsolePanel, BrowserJsDialogs, BrowserChromeLabels, TopControlSheet, EmbeddedFindBar, BottomConsoleSheet and EmbeddedPageDialogs, plus the strings only they used. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-pwa-parity.md | 4 + .../plans/2026-09-26-browser-ui-review.md | 46 +- .../loggedIn/browser/EmbeddedPageDialogs.kt | 163 ----- .../loggedIn/browser/EmbeddedPageRequests.kt | 7 +- .../browser/EmbeddedWebAppController.kt | 44 +- .../screen/loggedIn/browser/WebAppScreen.kt | 222 ++++-- .../loggedIn/embed/BottomConsoleSheet.kt | 237 ------- .../ui/screen/loggedIn/embed/ConsoleBridge.kt | 14 +- .../screen/loggedIn/embed/EmbeddedFindBar.kt | 123 ---- .../loggedIn/embed/EmbeddedTabChrome.kt | 34 +- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 174 +++-- .../screen/loggedIn/embed/TopControlSheet.kt | 459 ------------- .../loggedIn/favorites/NostrAppScreen.kt | 34 +- .../src/androidMain/res/values/strings.xml | 59 +- .../commons/browser/ui/pill/AddressEditor.kt | 20 +- .../browser/ui/pill/BrowserChromeTheme.kt | 35 + .../commons/browser/ui/pill/BrowserPill.kt | 13 +- .../browser/ui/pill/BrowserPillModel.kt | 3 + .../browser/ui/pill/BrowserPillPreviews.kt | 2 +- .../commons/browser/ui/pill/ConsoleSheet.kt | 7 + .../commons/browser/ui/pill/PillComponents.kt | 13 +- nappletHost/build.gradle.kts | 7 + .../amethyst/napplethost/BrowserChromeHost.kt | 388 +++++++++++ .../napplethost/BrowserChromeLabels.kt | 157 ----- .../amethyst/napplethost/BrowserFindBar.kt | 197 ------ .../amethyst/napplethost/BrowserJsDialogs.kt | 182 ----- .../amethyst/napplethost/BrowserWebTools.kt | 66 +- .../napplethost/NappletBrowserActivity.kt | 392 ++++++----- .../napplethost/NappletBrowserContract.kt | 11 +- .../napplethost/NappletBrowserService.kt | 7 +- .../napplethost/NappletConsolePanel.kt | 302 -------- .../napplethost/NappletControlSheet.kt | 647 ------------------ .../napplethost/NappletHostActivity.kt | 185 ++--- 33 files changed, 1212 insertions(+), 3042 deletions(-) delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserChromeTheme.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletConsolePanel.kt delete mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt diff --git a/amethyst/plans/2026-09-26-browser-pwa-parity.md b/amethyst/plans/2026-09-26-browser-pwa-parity.md index 115e1678a3..8c01bd3c6d 100644 --- a/amethyst/plans/2026-09-26-browser-pwa-parity.md +++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md @@ -292,6 +292,10 @@ nsite/napplet hosts (sandbox profile permitting). What shipped, where it lives, and what was deliberately left out. +> The two renderers described below (`TopControlSheet`, `NappletControlSheet` and their find, console and +> dialog views) were later replaced by one set of Compose components. See +> `2026-09-26-browser-ui-review.md` §5. + **Shared layout.** `commons/…/browser/BrowserChrome.kt` decides which actions the top pill shows, and in what order, for every surface (web / nsite / napplet × embedded / full screen), plus the security badge, the scope check, text-zoom steps, the desktop user agent and the theme-colour parser. It is covered by diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md index 58d0284e59..f87c24a31b 100644 --- a/amethyst/plans/2026-09-26-browser-ui-review.md +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -1,9 +1,9 @@ # Browser surfaces: UI review and redesign -Status: **review + Compose prototypes**. The prototypes live in +Status: **shipped** (see §5). The components live in `commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/`. They are rendered offscreen by `BrowserPillRenderTest` (commonsUI jvmTest), which writes PNGs to -`commonsUI/build/browser-pill/`. None of it is wired into the app yet (see §5). +`commonsUI/build/browser-pill/`. Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about how it looks and feels. @@ -222,15 +222,35 @@ A sheet with: | Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles | | Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` | -## 5. Next steps +## 5. What shipped -1. Wire the prototypes in: `TopControlSheet` → `BrowserPill`, `EmbeddedFindBar` → `FindInPagePill`, - `BottomConsoleSheet` → `ConsoleSheet`, and the embedded dialogs → `PermissionPromptCard` / - `PageDialogCard` / `PageInfoSheet`. -2. Host the same composables in the full-screen window through `ComposeView`, and delete - `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel` and `BrowserJsDialogs`. -3. Move the pill's labels from the `commons` Android resources to the Compose catalogue (the - prototypes already use `Res.string.browser_pill_*`). -4. Add "Only this time" to the permission flow (the registry already treats "no answer" as - not-remembered). -5. Confirm before clearing site data in both surfaces. +Every browser surface now draws the components in `commonsUI/…/browser/ui/pill/`; the hand-built chrome +is gone. + +- **Embedded tabs** (`EmbeddedTabLayer`): `EmbeddedTabChrome` now carries a `BrowserPillUi` and one + `onEvent(BrowserPillEvent)` callback. The layer draws `BrowserPill`, `FindInPagePill` and + `ConsoleSheet`, and handles find and the console itself. The address editor gets suggestions from + favorites and history (`OmniboxSuggestions`), and "Paste and go" checks only the clip's type. +- **Embedded page dialogs** (`WebAppScreen`): `PageDialogCard`, `PermissionPromptCard` and + `PageInfoSheet` in Compose `Dialog`s. `MSG_PAGE_INFO` now sends the certificate fields + (`KEY_CERT_ISSUED_TO` / `_BY` / `_VALID_UNTIL`) instead of a paragraph of text. Permissions in page + info are edited in place, straight into `WebSitePermissionRegistry`. +- **Full-screen windows** (`NappletBrowserActivity`, `NappletHostActivity`): `BrowserChromeHost` hosts + the same composables in two `ComposeView`s over the page. The top view grows to fill the window only + while the pill is open, so it can catch taps outside the pill; the bottom view holds find or the + console. Dialogs, the permission prompt and page info are Compose `Dialog`s. The browser asks the + broker for the site's decisions before showing page info. `:nappletHost` now applies the Compose + compiler and links the same JetBrains Compose libraries (Apache-2.0) the app already ships. +- **Permissions:** Allow while visiting (remembered), Only this time (granted, not remembered), and + Don't allow (remembered). Dismissing the prompt denies the request once and remembers nothing. +- **Clear site data** asks for confirmation inline, in both surfaces. +- **Removed:** `NappletControlSheet`, `BrowserFindBar`, `NappletConsolePanel`, `BrowserJsDialogs`, + `BrowserChromeLabels`, `TopControlSheet`, `EmbeddedFindBar`, `BottomConsoleSheet`, + `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). diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt deleted file mode 100644 index e7b4c6821e..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser - -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.height -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome -import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * An embedded page's `alert` / `confirm` / `prompt` / `beforeunload`, drawn by the main process over the tab - * (the provider's WebView has no window to draw one). Titled with the page's host ("example.com says") so a - * page can't pass its dialog off as Amethyst's own, and offering "Block dialogs from this page" from the - * page's second dialog on — the same rules as the full-screen browser. - */ -@Composable -fun EmbeddedJsDialogView( - dialog: EmbeddedJsDialog, - onAnswer: (confirmed: Boolean, text: String?, block: Boolean) -> Unit, -) { - var text by remember(dialog.id) { mutableStateOf(dialog.defaultValue) } - val leave = dialog.type == EmbeddedJsDialog.Type.BEFORE_UNLOAD - val host = dialog.url?.let { BrowserChrome.originOf(it) }?.let(BrowserChrome::displayHost) - AlertDialog( - onDismissRequest = { onAnswer(false, null, false) }, - title = { - Text( - when { - leave -> stringRes(CommonsR.string.browser_js_leave_title) - host != null -> stringRes(CommonsR.string.browser_js_dialog_title, host) - else -> stringRes(CommonsR.string.browser_js_dialog_title_generic) - }, - ) - }, - text = { - Column(Modifier.verticalScroll(rememberScrollState())) { - // The page's own beforeunload text is ignored, as in every current browser (it was abused). - Text(if (leave) stringRes(CommonsR.string.browser_js_leave_message) else dialog.message) - if (dialog.type == EmbeddedJsDialog.Type.PROMPT) { - Spacer(Modifier.height(12.dp)) - OutlinedTextField(value = text, onValueChange = { text = it }, singleLine = true, modifier = Modifier.fillMaxWidth()) - } - } - }, - confirmButton = { - TextButton(onClick = { onAnswer(true, if (dialog.type == EmbeddedJsDialog.Type.PROMPT) text else null, false) }) { - Text(if (leave) stringRes(CommonsR.string.browser_js_leave) else stringRes(android.R.string.ok)) - } - }, - dismissButton = { - Row { - if (dialog.offerBlock) { - // A blocked page may not keep the user on it, so blocking a beforeunload means leaving. - TextButton(onClick = { onAnswer(leave, null, true) }) { Text(stringRes(CommonsR.string.browser_js_dialog_block)) } - } - if (dialog.type != EmbeddedJsDialog.Type.ALERT) { - TextButton(onClick = { onAnswer(false, null, false) }) { Text(stringRes(android.R.string.cancel)) } - } - } - }, - ) -} - -/** - * Chrome's permission bubble for an embedded page: " wants to — use your camera — Block / Allow". - * Dismissing it denies this once without remembering anything. - */ -@Composable -fun EmbeddedPermissionPrompt( - origin: String, - permissions: Set, - onAnswer: (allow: Boolean?) -> Unit, -) { - AlertDialog( - onDismissRequest = { onAnswer(null) }, - title = { Text(stringRes(CommonsR.string.browser_permission_title, BrowserChrome.displayHost(origin))) }, - text = { - Column { - permissions.forEach { permission -> - Text( - "• " + - stringRes( - when (permission) { - BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera - BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone - BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location - }, - ), - ) - } - } - }, - confirmButton = { TextButton(onClick = { onAnswer(true) }) { Text(stringRes(CommonsR.string.browser_permission_allow)) } }, - dismissButton = { TextButton(onClick = { onAnswer(false) }) { Text(stringRes(CommonsR.string.browser_permission_block)) } }, - ) -} - -/** Chrome's page-info sheet for an embedded page: connection, Tor, certificate; site settings; clear data. */ -@Composable -fun EmbeddedPageInfoDialog( - host: String, - info: String?, - onPermissions: (() -> Unit)?, - onClearData: () -> Unit, - onDismiss: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(host) }, - text = { Text(info ?: "…") }, - confirmButton = { TextButton(onClick = onDismiss) { Text(stringRes(android.R.string.ok)) } }, - dismissButton = { - Row { - TextButton(onClick = { - onClearData() - onDismiss() - }) { Text(stringRes(CommonsR.string.browser_page_info_clear_data)) } - if (onPermissions != null) { - TextButton(onClick = { - onPermissions() - onDismiss() - }) { Text(stringRes(CommonsR.string.browser_page_info_permissions)) } - } - } - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt index 90d9ff904b..65eedabf6d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -21,19 +21,18 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType /** A JS dialog an embedded page opened, waiting for the user (the page's script is paused meanwhile). */ data class EmbeddedJsDialog( val id: Long, - val type: Type, + val type: PageDialogType, val url: String?, val message: String, val defaultValue: String, /** Offer "Block dialogs from this page" (from the page's second dialog on, as Chrome does). */ val offerBlock: Boolean, -) { - enum class Type { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD } -} +) /** A camera / microphone / location request from an embedded page, waiting for an answer. */ data class EmbeddedPermissionRequest( 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 427b3376d5..4cd48b1181 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 @@ -42,11 +42,13 @@ 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.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge -import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleLogEntry 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 @@ -102,7 +104,7 @@ class EmbeddedWebAppController( override var onLoadStatusChanged: ((EmbeddedLoadStatus) -> Unit)? = null /** JavaScript console output received from the embedded WebView, capped at [MAX_CONSOLE_LOGS] entries. */ - override val consoleLogs = mutableStateListOf() + override val consoleLogs = mutableStateListOf() override fun clearConsoleLogs() = consoleLogs.clear() @@ -126,8 +128,8 @@ class EmbeddedWebAppController( /** The camera / microphone / location request the page is waiting on, if any. */ val pendingPermission = mutableStateOf(null) - /** Page-info text for the page on screen, once requested. */ - val pageInfo = mutableStateOf(null) + /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ + val pageCertificate = mutableStateOf(null) /** A main-frame load is in flight (the pill's reload button becomes stop). */ val isLoading = mutableStateOf(false) @@ -284,7 +286,7 @@ class EmbeddedWebAppController( val source = msg.data?.getString(NappletBrowserContract.KEY_CONSOLE_SOURCE).orEmpty() val line = msg.data?.getInt(NappletBrowserContract.KEY_CONSOLE_LINE, 0) ?: 0 if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) - consoleLogs.add(ConsoleLogEntry(level, message, source, line)) + consoleLogs.add(ConsoleLine(consoleLevelOf(level), message, source, line)) } NappletBrowserContract.MSG_FILE_CHOOSER_REQUEST -> { val data = msg.data ?: return true @@ -309,7 +311,17 @@ class EmbeddedWebAppController( val data = msg.data ?: return true _findResult.value = FindResult(data.getInt(NappletBrowserContract.KEY_FIND_ACTIVE), data.getInt(NappletBrowserContract.KEY_FIND_TOTAL)) } - NappletBrowserContract.MSG_PAGE_INFO -> pageInfo.value = msg.data?.getString(NappletBrowserContract.KEY_PAGE_INFO) + NappletBrowserContract.MSG_PAGE_INFO -> { + val data = msg.data ?: return true + pageCertificate.value = + data.getString(NappletBrowserContract.KEY_CERT_ISSUED_TO)?.let { issuedTo -> + CertificateInfo( + issuedTo = issuedTo, + issuedBy = data.getString(NappletBrowserContract.KEY_CERT_ISSUED_BY).orEmpty(), + validUntil = data.getString(NappletBrowserContract.KEY_CERT_VALID_UNTIL).orEmpty(), + ) + } + } NappletBrowserContract.MSG_JS_DIALOG -> { val data = msg.data ?: return true val id = data.getLong(NappletBrowserContract.KEY_DIALOG_ID) @@ -323,10 +335,10 @@ class EmbeddedWebAppController( id = id, type = when (data.getString(NappletBrowserContract.KEY_DIALOG_TYPE)) { - "confirm" -> EmbeddedJsDialog.Type.CONFIRM - "prompt" -> EmbeddedJsDialog.Type.PROMPT - "beforeunload" -> EmbeddedJsDialog.Type.BEFORE_UNLOAD - else -> EmbeddedJsDialog.Type.ALERT + "confirm" -> PageDialogType.CONFIRM + "prompt" -> PageDialogType.PROMPT + "beforeunload" -> PageDialogType.BEFORE_UNLOAD + else -> PageDialogType.ALERT }, url = data.getString(NappletBrowserContract.KEY_URL), message = data.getString(NappletBrowserContract.KEY_DIALOG_MESSAGE).orEmpty(), @@ -439,7 +451,7 @@ class EmbeddedWebAppController( fun clearSiteData() = send(NappletBrowserContract.MSG_CLEAR_SITE_DATA) {} fun requestPageInfo() { - pageInfo.value = null + pageCertificate.value = null send(NappletBrowserContract.MSG_PAGE_INFO_REQUEST) {} } @@ -513,3 +525,13 @@ 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/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index 4c26663a39..2abb3ecdcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -30,7 +30,9 @@ import androidx.activity.result.contract.ActivityResultContracts 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.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text @@ -49,6 +51,9 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -57,6 +62,13 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds @@ -64,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_unsupported import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes +import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.WebShortcuts @@ -171,71 +184,101 @@ private fun EmbeddedWebAppTab( browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) } } + fun onAction(action: BrowserChrome.Action) { + when (action) { + BrowserChrome.Action.BACK -> controller.back() + BrowserChrome.Action.FORWARD -> controller.forward() + BrowserChrome.Action.RELOAD -> controller.reload() + BrowserChrome.Action.STOP -> controller.stop() + BrowserChrome.Action.FAVORITE -> toggleFavorite() + BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl) + BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url) + BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl) + BrowserChrome.Action.DESKTOP_SITE -> { + desktopSite = !desktopSite + controller.setDesktopSite(desktopSite) + } + BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl)) + BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl) + // The page the user is looking at, not the one the tab was pinned with. + BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl) + BrowserChrome.Action.TOR -> { + torOn = !torOn + controller.setTor(torOn) + WebAppNetworkRegistry.set(currentUrl, torOn) + } + BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings() + else -> Unit + } + } + + fun onNavigate(text: String) { + val resolved = OmniboxInput.resolve(text) ?: return + // .onion only resolves over Tor. + if (resolved.forceTor && proxyAvailable && !torOn) { + torOn = true + controller.setTor(true) + } + controller.navigate(resolved.url) + } + + // Favorites first, then history: what the address editor offers for what the user has typed. + val history by BrowserHistoryRegistry.history.collectAsStateWithLifecycle() + val candidates = + remember(apps, history) { + buildList { + apps.forEach { if (it is FavoriteApp.WebApp) add(OmniboxSuggestions.Candidate(it.url, it.label, isFavorite = true)) } + history.forEach { + add(OmniboxSuggestions.Candidate(it.url, it.title.ifBlank { it.host }, isFavorite = false, visitCount = it.visitCount, lastVisitedAt = it.lastVisitedAt)) + } + } + } + + val siteDecisions by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle() + val sitePermissions = remember(siteDecisions, currentUrl) { browserOrigin(currentUrl)?.let { siteDecisions[it] }.orEmpty() } + // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, controller) { + remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) { EmbeddedTabChrome( - title = pageTitle ?: hostLabel(currentUrl), - state = - BrowserChrome.State( - surface = BrowserChrome.Surface.WEB, - presentation = BrowserChrome.Presentation.EMBEDDED, - url = currentUrl, - startUrl = url, - canGoBack = canGoBack, - canGoForward = canGoForward, - isLoading = isLoading, - torOn = if (proxyAvailable) torOn else null, - hasSiteSettings = browserOrigin(currentUrl) != null, + ui = + BrowserPillUi( + title = pageTitle ?: hostLabel(currentUrl), + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.EMBEDDED, + url = currentUrl, + startUrl = url, + canGoBack = canGoBack, + canGoForward = canGoForward, + isLoading = isLoading, + torOn = if (proxyAvailable) torOn else null, + hasSiteSettings = browserOrigin(currentUrl) != null, + ), + isFavorite = isFavorite, + desktopSite = desktopSite, + textZoom = textZoom, + sitePermissions = sitePermissions, ), - isFavorite = isFavorite, - desktopSite = desktopSite, - textZoom = textZoom, - onAction = { action -> - when (action) { - BrowserChrome.Action.BACK -> controller.back() - BrowserChrome.Action.FORWARD -> controller.forward() - BrowserChrome.Action.RELOAD -> controller.reload() - BrowserChrome.Action.STOP -> controller.stop() - BrowserChrome.Action.FAVORITE -> toggleFavorite() - BrowserChrome.Action.SHARE -> BrowserWebTools.share(context, pageTitle, null, currentUrl) - BrowserChrome.Action.BACK_TO_APP -> controller.backToScope(url) - BrowserChrome.Action.COPY_LINK -> BrowserWebTools.copyToClipboard(context, currentUrl) - BrowserChrome.Action.DESKTOP_SITE -> { - desktopSite = !desktopSite - controller.setDesktopSite(desktopSite) + onEvent = { event -> + when (event) { + is BrowserPillEvent.Action -> onAction(event.action) + is BrowserPillEvent.Navigate -> onNavigate(event.input) + is BrowserPillEvent.TextZoom -> { + textZoom = event.percent + controller.setTextZoom(event.percent) } - BrowserChrome.Action.ADD_TO_HOME_SCREEN -> WebShortcuts.requestPin(context, currentUrl, pageTitle ?: hostLabel(currentUrl)) - BrowserChrome.Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(context, currentUrl) - // The page the user is looking at, not the one the tab was pinned with. - BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launchUrl(context, currentUrl) - BrowserChrome.Action.TOR -> { - torOn = !torOn - controller.setTor(torOn) - WebAppNetworkRegistry.set(currentUrl, torOn) + BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(context, currentUrl) + BrowserPillEvent.PageInfo -> { + controller.requestPageInfo() + showPageInfo = true } - BrowserChrome.Action.SITE_SETTINGS -> openSiteSettings() - else -> Unit + BrowserPillEvent.Close -> Unit } }, - onNavigate = { text -> - val resolved = OmniboxInput.resolve(text) - if (resolved != null) { - // .onion only resolves over Tor. - if (resolved.forceTor && proxyAvailable && !torOn) { - torOn = true - controller.setTor(true) - } - controller.navigate(resolved.url) - } - }, - onTextZoom = { percent -> - textZoom = percent - controller.setTextZoom(percent) - }, - onOriginTap = { - controller.requestPageInfo() - showPageInfo = true + suggestionsFor = { typed -> + OmniboxSuggestions.rank(typed, candidates, limit = 5).map { AddressSuggestion(it.label, it.url, it.isFavorite) } }, ) } @@ -259,7 +302,7 @@ private fun EmbeddedWebAppTab( // A fullscreen video inside the tab: back leaves fullscreen first, as in Chrome. BackHandler(enabled = isFullscreen) { controller.exitFullscreen() } - EmbeddedPageUi(controller, currentUrl, showPageInfo, onPageInfoDismiss = { showPageInfo = false }, onSiteSettings = ::openSiteSettings) + EmbeddedPageUi(controller, chrome.ui, showPageInfo, onPageInfoDismiss = { showPageInfo = false }) Scaffold( bottomBar = { @@ -280,22 +323,32 @@ private fun EmbeddedWebAppTab( /** * Everything an embedded page asks the user for, drawn by the main process because the provider has no * window: JS dialogs, camera / microphone / location prompts (remembered per origin in - * [WebSitePermissionRegistry], then Android's own runtime permission), and page info. + * [WebSitePermissionRegistry], then Android's own runtime permission), and page info — the shared + * [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet]. */ @RequiresApi(Build.VERSION_CODES.R) @Composable private fun EmbeddedPageUi( controller: EmbeddedWebAppController, - currentUrl: String, + ui: BrowserPillUi, showPageInfo: Boolean, onPageInfoDismiss: () -> Unit, - onSiteSettings: () -> Unit, ) { val context = LocalContext.current val dialog by controller.pendingDialog dialog?.let { d -> - EmbeddedJsDialogView(d) { confirmed, text, block -> controller.answerDialog(d.id, confirmed, text, block) } + Dialog(onDismissRequest = { controller.answerDialog(d.id, confirmed = false) }) { + PageDialogCard( + type = d.type, + host = d.url?.let(::browserOrigin)?.let(::hostLabel), + security = ui.security, + message = d.message, + defaultValue = d.defaultValue, + offerBlock = d.offerBlock, + onResult = { confirmed, text, block -> controller.answerDialog(d.id, confirmed, text, block) }, + ) + } } // Android's runtime permission, asked only for what the user allowed the site to use. @@ -330,26 +383,47 @@ private fun EmbeddedPageUi( if (ask.isEmpty()) { LaunchedEffect(request.id) { grant(request.id, allowed) } } else { - EmbeddedPermissionPrompt(request.origin, ask) { allow -> - // null = dismissed without an answer: deny this once, remember nothing. - if (allow != null) { + // allow: grant now; remember: store the answer for the site ("Only this time" and a dismissal don't). + fun answer( + allow: Boolean, + remember: Boolean, + ) { + if (remember) { val decision = if (allow) BrowserSitePermission.Decision.ALLOW else BrowserSitePermission.Decision.BLOCK ask.forEach { WebSitePermissionRegistry.set(request.origin, it, decision) } } - grant(request.id, if (allow == true) allowed + ask else allowed) + grant(request.id, if (allow) allowed + ask else allowed) + } + Dialog(onDismissRequest = { answer(allow = false, remember = false) }) { + PermissionPromptCard( + host = hostLabel(request.origin), + security = ui.security, + permissions = ask, + onAllow = { answer(allow = true, remember = true) }, + onAllowOnce = { answer(allow = true, remember = false) }, + onDeny = { answer(allow = false, remember = true) }, + ) } } } if (showPageInfo) { - val info by controller.pageInfo - EmbeddedPageInfoDialog( - host = hostLabel(currentUrl), - info = info, - onPermissions = if (browserOrigin(currentUrl) != null) onSiteSettings else null, - onClearData = { controller.clearSiteData() }, - onDismiss = onPageInfoDismiss, - ) + val certificate by controller.pageCertificate + val origin = browserOrigin(ui.chrome.url) + Dialog(onDismissRequest = onPageInfoDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + PageInfoSheet( + ui = ui, + certificate = certificate, + onPermissionChange = { permission, decision -> origin?.let { WebSitePermissionRegistry.set(it, permission, decision) } }, + onClearSiteData = { + onPageInfoDismiss() + controller.clearSiteData() + }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt deleted file mode 100644 index 5558cfdcae..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/BottomConsoleSheet.kt +++ /dev/null @@ -1,237 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -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.HorizontalDivider -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.runtime.LaunchedEffect -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * A bottom **pull-up sheet** for JavaScript console output. Collapsed it's a small grabber at the - * bottom edge of the embedded surface. Pull it up (or tap) to reveal a scrollable log of console - * messages captured from the page via [WebChromeClient.onConsoleMessage]. The page can't draw over - * it (the surface is z-ordered below this layer). - * - * Mirrors [TopControlSheet]'s pattern but anchored at the bottom using a [Box] with - * [Alignment.BottomCenter]. - */ -@Composable -fun BottomConsoleSheet( - logs: List, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - onClear: () -> Unit, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier, contentAlignment = Alignment.BottomCenter) { - // Column anchored at BottomCenter. Grabber sits at the top of the column so it rides up - // with the panel as it expands — standard bottom-sheet handle behaviour. - Column( - modifier = Modifier.align(Alignment.BottomCenter), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Grabber — the only touch target when collapsed; stays at the top of the panel when open - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .clip(RoundedCornerShape(topStart = 10.dp, topEnd = 10.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - .clickable { onExpandedChange(!expanded) } - .draggable( - orientation = Orientation.Vertical, - state = - rememberDraggableState { delta -> - if (delta < -1f) { - onExpandedChange(true) - } else if (delta > 1f) { - onExpandedChange(false) - } - }, - ).padding(horizontal = 16.dp, vertical = 7.dp), - ) { - Spacer( - Modifier - .width(36.dp) - .height(5.dp) - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)), - ) - } - - AnimatedVisibility( - visible = expanded, - enter = expandVertically(expandFrom = Alignment.Bottom) + fadeIn(), - exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + fadeOut(), - ) { - Surface( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), - contentColor = MaterialTheme.colorScheme.onSurface, - // No elevation. This panel docks flush against the app's bottom navigation bar, so a - // shadowElevation just casts a shadow onto that bar — a seam that breaks the flush, - // background-matched look. The tonalElevation had no visual effect here anyway (the color - // isn't exactly colorScheme.surface, so Material never applies the surfaceTint); the - // shadow was the only thing clashing with the nav bar. The grabber + divider still - // separate the panel from the page above it. - tonalElevation = 0.dp, - shadowElevation = 0.dp, - shape = RoundedCornerShape(bottomStart = 0.dp, bottomEnd = 0.dp), - ) { - val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.4f).dp - Column(Modifier.fillMaxWidth().heightIn(max = maxHeight)) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - stringRes(CommonsR.string.browser_console_title, logs.size), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = onClear) { - Text( - stringRes(CommonsR.string.browser_console_clear), - style = MaterialTheme.typography.labelSmall, - ) - } - } - HorizontalDivider() - val scrollState = rememberScrollState() - LaunchedEffect(logs.size) { scrollState.scrollTo(Int.MAX_VALUE) } - Column( - Modifier - .fillMaxWidth() - .verticalScroll(scrollState) - .padding(horizontal = 8.dp, vertical = 4.dp), - ) { - logs.forEach { entry -> - ConsoleLogRow(entry) - } - } - } - } - } - } - } -} - -@Composable -private fun ConsoleLogRow(entry: ConsoleLogEntry) { - val levelColor = consoleLevelColor(entry.level) - val dimColor = MaterialTheme.colorScheme.onSurfaceVariant - val srcShort = - entry.source - .substringAfterLast("/") - .substringAfterLast("\\") - .let { if (it.isBlank()) entry.source.takeLast(20) else it } - Row( - Modifier.fillMaxWidth().padding(vertical = 1.dp), - verticalAlignment = Alignment.Top, - ) { - Text( - consoleLevelChar(entry.level), - color = levelColor, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - modifier = Modifier.width(14.dp), - ) - Spacer(Modifier.width(4.dp)) - Text( - buildAnnotatedString { - withStyle(SpanStyle(color = levelColor)) { - append(entry.message) - } - if (srcShort.isNotBlank()) { - withStyle(SpanStyle(color = dimColor)) { - append(" $srcShort:${entry.lineNumber}") - } - } - }, - fontFamily = FontFamily.Monospace, - fontSize = 11.sp, - modifier = Modifier.weight(1f), - overflow = TextOverflow.Visible, - ) - } -} - -@Composable -private fun consoleLevelColor(level: String): Color { - val warningAmber = if (isSystemInDarkTheme()) Color(0xFFFFB74D) else Color(0xFFE65100) - return when (level.uppercase()) { - "ERROR" -> MaterialTheme.colorScheme.error - "WARNING" -> warningAmber - "TIP" -> MaterialTheme.colorScheme.tertiary - "DEBUG" -> MaterialTheme.colorScheme.onSurfaceVariant - else -> MaterialTheme.colorScheme.onSurface - } -} - -private fun consoleLevelChar(level: String): String = - when (level.uppercase()) { - "ERROR" -> "E" - "WARNING" -> "W" - "TIP" -> "T" - "DEBUG" -> "D" - else -> "I" - } 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 83875219dc..84e3bb70be 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 @@ -21,21 +21,15 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed import androidx.compose.runtime.snapshots.SnapshotStateList +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine /** * A surface controller that exposes JavaScript console output captured from the embedded WebView. - * The [consoleLogs] list is Compose snapshot state so [BottomConsoleSheet] recomposes as messages - * arrive. Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController]. + * The [consoleLogs] list is Compose snapshot state so the console sheet recomposes as messages arrive. + * Implemented by [com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.EmbeddedWebAppController]. */ interface ConsoleBridge { - val consoleLogs: SnapshotStateList + val consoleLogs: SnapshotStateList fun clearConsoleLogs() } - -data class ConsoleLogEntry( - val level: String, - val message: String, - val source: String, - val lineNumber: Int, -) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt deleted file mode 100644 index 6d2492c951..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt +++ /dev/null @@ -1,123 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * Find in page for the active embedded tab, docked at the bottom of its bounds (the top edge belongs to the - * pill's grabber). Mirrors the full-screen browser's native find bar: query, "n/m", previous, next, close. - */ -@Composable -fun EmbeddedFindBar( - bridge: FindBridge, - onClose: () -> Unit, - modifier: Modifier = Modifier, -) { - var query by remember { mutableStateOf("") } - val result by bridge.findResult - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { focus.requestFocus() } - - fun close() { - bridge.find("") - onClose() - } - - Box(modifier, contentAlignment = Alignment.BottomCenter) { - Surface( - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - tonalElevation = 0.dp, - shadowElevation = 6.dp, - shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp), - ) { - Row(Modifier.fillMaxWidth().padding(start = 4.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { - TextField( - value = query, - onValueChange = { - query = it - bridge.find(it) - }, - modifier = Modifier.weight(1f).focusRequester(focus), - singleLine = true, - placeholder = { Text(stringRes(CommonsR.string.browser_find_hint)) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { bridge.findNext(true) }), - colors = - TextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) - val counts = result - if (query.isNotEmpty() && counts != null) { - Text( - if (counts.total > 0) stringRes(CommonsR.string.browser_find_count, counts.active + 1, counts.total) else "0/0", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 4.dp), - ) - } - IconButton(onClick = { bridge.findNext(false) }) { - Icon(MaterialSymbols.KeyboardArrowUp, contentDescription = stringRes(CommonsR.string.browser_find_previous)) - } - IconButton(onClick = { bridge.findNext(true) }) { - Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = stringRes(CommonsR.string.browser_find_next)) - } - IconButton(onClick = ::close) { - Icon(MaterialSymbols.Close, contentDescription = stringRes(CommonsR.string.browser_find_close)) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt index 5e8817bbb4..200704f352 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt @@ -20,28 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi /** - * The controls a running app surface offers through its top pull-down pill — described as plain data so - * [EmbeddedTabLayer] can draw the sheet over the (z-below) surface for the active tab. Deliberately not a - * corner pill: that's where a site usually puts the user's own avatar/menu, so the handle lives at the - * top-center instead. + * What a running app surface shows in its top pull-down pill, as plain data so [EmbeddedTabLayer] can draw + * the shared [com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill] over the (z-below) surface + * for the active tab. Deliberately not a corner pill: that's where a site usually puts the user's own + * avatar/menu, so the handle lives at the top-center instead. * - * *Which* actions show, and in what order, comes from [BrowserChrome] with [state] — the same layout the - * full-screen browser's native sheet uses — so the two can't drift. Everything the user picks arrives in - * [onAction]; the console and find-in-page rows are handled by the layer itself. + * *Which* actions show comes from [BrowserPillUi.chrome] through + * [com.vitorpamplona.amethyst.commons.browser.BrowserChrome] — the same layout the full-screen windows + * use. Everything the user picks arrives in [onEvent]; find in page and the console are handled by the + * layer itself, since it draws them. */ data class EmbeddedTabChrome( - val title: String, - val state: BrowserChrome.State, - val isFavorite: Boolean = false, - val desktopSite: Boolean = false, - val textZoom: Int = BrowserChrome.DEFAULT_TEXT_ZOOM, - val onAction: (BrowserChrome.Action) -> Unit, - /** The user typed an address into "Edit address" and pressed Go. */ - val onNavigate: (String) -> Unit = {}, - val onTextZoom: (Int) -> Unit = {}, - /** The origin chip was tapped: page info (web) or the access summary (sandboxed apps). */ - val onOriginTap: () -> Unit = {}, + val ui: BrowserPillUi, + val onEvent: (BrowserPillEvent) -> Unit, + /** Address-editor suggestions for what the user has typed. */ + val suggestionsFor: (String) -> List = { emptyList() }, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 7ca4b02c25..ba49a8480c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -83,7 +83,14 @@ import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.privacysandbox.ui.client.view.SandboxedSdkView +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.ui.EmbeddedLoadOverlay +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill +import com.vitorpamplona.amethyst.napplethost.BrowserWebTools import kotlinx.coroutines.delay import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put @@ -126,7 +133,7 @@ private fun EmbeddedImeBridge.sendFieldOp( * * The surface is z-ordered *below* the client window (privacysandbox.ui locks it there), which still * forwards touch input to the provider yet lets Compose draw over it — so the active tab's - * [TopControlSheet] is rendered on top of the surface here. Each surface is wrapped in an + * pill ([com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill]) is rendered on top of the surface here. Each surface is wrapped in an * [EmbeddedSurfaceTouchHolder] so a scroll gesture isn't stolen by a host-side ancestor (the * cross-process WebView can't defend its own gesture). * @@ -268,107 +275,117 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { } } - // The active tab's top pull-down sheet, drawn AFTER the surfaces so it sits on top of the - // (z-below) surface, anchored to the top of the active tab's reserved bounds. Its expanded state - // is owned here (reset per tab) so we can draw a full-area dismiss scrim behind the open sheet — - // while collapsed, only the small grabber is interactive and page taps pass through. + // The active tab's top pull-down pill, drawn AFTER the surfaces so it sits on top of the (z-below) + // surface, anchored to the top of the active tab's reserved bounds. Its expanded state is owned here + // (reset per tab) so we can draw a full-area dismiss scrim behind the open pill — while collapsed, + // only the small grabber is interactive and page taps pass through. val chrome = EmbeddedTabHost.activeChrome val consoleBridge = activeController as? ConsoleBridge - val consoleCount = consoleBridge?.consoleLogs?.size ?: 0 val findBridge = activeController as? FindBridge if (chrome != null && bounds.width > 0f && bounds.height > 0f) { - var sheetExpanded by remember(activeId) { mutableStateOf(false) } + var pillExpanded by remember(activeId) { mutableStateOf(false) } var consoleShowing by remember(activeId) { mutableStateOf(false) } - var consoleExpanded by remember(activeId) { mutableStateOf(false) } var findShowing by remember(activeId) { mutableStateOf(false) } + var findQuery by remember(activeId) { mutableStateOf("") } + val context = LocalContext.current - if (sheetExpanded) { + fun closeFind() { + if (findShowing) findBridge?.find("") + findShowing = false + findQuery = "" + } + + val tabModifier = + with(density) { + Modifier + .absoluteOffset( + (bounds.left - layerOrigin.x).toDp(), + (bounds.top - layerOrigin.y).toDp(), + ).size(bounds.width.toDp(), bounds.height.toDp()) + } + + if (pillExpanded) { + BackHandler { pillExpanded = false } Box( Modifier .fillMaxSize() .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, - ) { sheetExpanded = false }, + ) { pillExpanded = false }, ) } - with(density) { - TopControlSheet( - chrome = chrome, - expanded = sheetExpanded, - onExpandedChange = { sheetExpanded = it }, - consoleCount = consoleCount, + val consoleLogs = consoleBridge?.consoleLogs + val ui = + chrome.ui.copy( + chrome = chrome.ui.chrome.copy(hasFind = chrome.ui.chrome.hasFind && findBridge != null), consoleShowing = consoleShowing, - onConsole = - if (consoleBridge != null) { - { - if (consoleShowing) { - consoleShowing = false - } else { - // One bottom panel at a time: the console replaces the find bar. - if (findShowing) findBridge?.find("") - findShowing = false - consoleShowing = true - consoleExpanded = true - } - } - } else { - null - }, - onFind = - if (findBridge != null) { - { + consoleErrors = consoleLogs?.count { it.level == ConsoleLine.Level.ERROR } ?: 0, + ) + + Box(tabModifier) { + BrowserPill( + ui = ui, + expanded = pillExpanded, + onExpandedChange = { pillExpanded = it }, + onEvent = { event -> + val action = (event as? BrowserPillEvent.Action)?.action + when { + action == BrowserChrome.Action.FIND_IN_PAGE && findBridge != null -> { + // One bottom panel at a time: find replaces the console. consoleShowing = false findShowing = true } + action == BrowserChrome.Action.CONSOLE && consoleBridge != null -> { + if (!consoleShowing) closeFind() + consoleShowing = !consoleShowing + } + else -> chrome.onEvent(event) + } + }, + showClose = false, + suggestionsFor = chrome.suggestionsFor, + onPasteAndGo = + if (BrowserWebTools.clipboardHasText(context)) { + { + pillExpanded = false + BrowserWebTools.clipboardText(context)?.let { chrome.onEvent(BrowserPillEvent.Navigate(it)) } + } } else { null }, - modifier = - Modifier - .absoluteOffset( - (bounds.left - layerOrigin.x).toDp(), - (bounds.top - layerOrigin.y).toDp(), - ).width(bounds.width.toDp()), + modifier = Modifier.align(Alignment.TopCenter), ) - } - // Find in page: opened via the "Find in page" row in the top pull-down sheet. - if (findShowing && findBridge != null) { - BackHandler { - findBridge.find("") - findShowing = false - } - with(density) { - EmbeddedFindBar( - bridge = findBridge, - onClose = { findShowing = false }, - modifier = - Modifier - .absoluteOffset( - (bounds.left - layerOrigin.x).toDp(), - (bounds.top - layerOrigin.y).toDp(), - ).size(bounds.width.toDp(), bounds.height.toDp()), + // Find in page: opened from the pill's Find tile. + if (findShowing && findBridge != null) { + BackHandler { closeFind() } + val result by findBridge.findResult + FindInPagePill( + query = findQuery, + onQueryChange = { + findQuery = it + findBridge.find(it) + }, + active = result?.active ?: 0, + total = result?.total, + onNext = findBridge::findNext, + onClose = ::closeFind, + modifier = Modifier.align(Alignment.BottomCenter), ) } - } - // Bottom console panel: opened via the "Console" row in the top pull-down sheet. - if (consoleShowing && consoleBridge != null) { - with(density) { - BottomConsoleSheet( - logs = consoleBridge.consoleLogs, - expanded = consoleExpanded, - onExpandedChange = { consoleExpanded = it }, + // The developer console: opened from the pill's console row. + if (consoleShowing && consoleLogs != null) { + ConsoleSheet( + lines = consoleLogs, + onCopy = { lines -> BrowserWebTools.copyText(context, "console", lines.joinToString("\n", transform = ::formatConsoleLine)) }, onClear = { consoleBridge.clearConsoleLogs() }, - modifier = - Modifier - .absoluteOffset( - (bounds.left - layerOrigin.x).toDp(), - (bounds.top - layerOrigin.y).toDp(), - ).size(bounds.width.toDp(), bounds.height.toDp()), + onCopyLine = { BrowserWebTools.copyText(context, "console", formatConsoleLine(it)) }, + onClose = { consoleShowing = false }, + modifier = Modifier.align(Alignment.BottomCenter), ) } } @@ -1001,3 +1018,16 @@ private fun SelectionToolbarItem( }.padding(horizontal = 12.dp, vertical = 10.dp), ) } + +/** One console line as plain text, for copying. */ +private fun formatConsoleLine(line: ConsoleLine): String = + buildString { + append(line.level.name).append(": ").append(line.message) + if (line.source.isNotBlank()) { + append(" (") + .append(line.source) + .append(':') + .append(line.line) + .append(')') + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt deleted file mode 100644 index 892cdba6d3..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ /dev/null @@ -1,459 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed - -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.expandVertically -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -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.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TextFieldDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -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.browser.BrowserChrome.Action -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.napplethost.BrowserChromeLabels -import com.vitorpamplona.amethyst.ui.stringRes -import androidx.compose.material3.Icon as Material3Icon -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * A top **pull-down pill** for a running app surface. Collapsed it's just a small grabber centered at the - * very top edge — out of the corner where a site puts its own avatar/menu. Pulled down (or tapped) it shows a - * Chrome-PWA-style app menu: the page title with its origin and connection badge (tap for page info), the - * icon row (back · forward · reload/stop · star · share), then the menu rows and the Privacy and Developer - * groups. The page can't draw over it (the surface is z-ordered below this layer). - * - * The layout comes from [BrowserChrome] and the icons/labels from [BrowserChromeLabels] — both shared with - * the full-screen browser's native sheet — so an action looks and sits the same in either. - * - * @param onConsole When non-null, CONSOLE toggles the log panel through it ([consoleCount] messages so far). - * @param onFind When non-null, FIND_IN_PAGE opens the find bar through it. - */ -@Composable -fun TopControlSheet( - chrome: EmbeddedTabChrome, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - consoleCount: Int = 0, - consoleShowing: Boolean = false, - onConsole: (() -> Unit)? = null, - onFind: (() -> Unit)? = null, -) { - val state = remember(chrome.state, onConsole) { if (onConsole == null) chrome.state.copy(hasConsole = false) else chrome.state } - var editingAddress by remember(expanded) { mutableStateOf(false) } - - fun act(action: Action) { - when (action) { - Action.CONSOLE -> onConsole?.invoke() - Action.FIND_IN_PAGE -> onFind?.invoke() - else -> chrome.onAction(action) - } - } - - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - AnimatedVisibility( - visible = expanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut(), - ) { - Surface( - color = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - // No tonal elevation. Material 3 only recolors a Surface whose color is EXACTLY - // colorScheme.surface — it swaps in surfaceColorAtElevation(), which blends surfaceTint - // (= primary, Amethyst's purple) over the surface. On the light theme that near-white + - // purple mix reads as a pink/lilac cast instead of the plain background the sheet should - // have. Keep the drop shadow (shadowElevation) to lift the sheet off the page below it. - tonalElevation = 0.dp, - shadowElevation = 6.dp, - shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp), - ) { - Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { - SheetHeader( - chrome = chrome, - state = state, - editingAddress = editingAddress, - onOriginTap = { - onExpandedChange(false) - chrome.onOriginTap() - }, - onCopy = { chrome.onAction(Action.COPY_LINK) }, - onNavigate = { text -> - editingAddress = false - onExpandedChange(false) - chrome.onNavigate(text) - }, - ) - IconActionRow(state, chrome.isFavorite) { action -> - onExpandedChange(false) - act(action) - } - HorizontalDivider() - val maxHeight = (LocalConfiguration.current.screenHeightDp * 0.55f).dp - Column(Modifier.heightIn(max = maxHeight).verticalScroll(rememberScrollState())) { - BrowserChrome.sections(state).forEachIndexed { index, section -> - if (index > 0) HorizontalDivider(Modifier.padding(vertical = 2.dp)) - sectionTitle(section.kind)?.let { - Text( - it.uppercase(), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 2.dp), - ) - } - section.actions.forEach { action -> - when { - action == Action.TEXT_SIZE -> TextSizeRow(chrome.textZoom, chrome.onTextZoom) - BrowserChromeLabels.isToggle(action) -> - SheetSwitchItem( - action = action, - label = rowLabel(action, state, chrome.isFavorite, consoleCount), - checked = - when (action) { - Action.TOR -> state.torOn == true - Action.DESKTOP_SITE -> chrome.desktopSite - else -> consoleShowing - }, - ) { - onExpandedChange(false) - act(action) - } - else -> - SheetItem(action, rowLabel(action, state, chrome.isFavorite, consoleCount)) { - if (action == Action.EDIT_ADDRESS) { - editingAddress = true - } else { - onExpandedChange(false) - act(action) - } - } - } - } - } - } - } - } - } - - // The grabber: a small rounded bar centered at the top edge. It is the ONLY touch target the sheet - // draws — the rest of the top strip stays transparent so page taps pass straight through to the - // surface below. Pull down to open, up to close; tapping toggles. - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = - Modifier - .clip(RoundedCornerShape(bottomStart = 12.dp, bottomEnd = 12.dp)) - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)) - .clickable { onExpandedChange(!expanded) } - .draggable( - orientation = Orientation.Vertical, - state = - rememberDraggableState { delta -> - if (delta > 1f) { - onExpandedChange(true) - } else if (delta < -1f) { - onExpandedChange(false) - } - }, - ).padding(horizontal = 16.dp, vertical = 7.dp), - ) { - Spacer( - Modifier - .width(36.dp) - .height(5.dp) - .clip(RoundedCornerShape(50)) - .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)), - ) - } - } -} - -@Composable -private fun sectionTitle(kind: SectionKind): String? = - when (kind) { - SectionKind.PAGE -> null - SectionKind.PRIVACY -> stringRes(CommonsR.string.browser_section_privacy) - SectionKind.DEVELOPER -> stringRes(CommonsR.string.browser_section_developer) - } - -@Composable -private fun rowLabel( - action: Action, - state: BrowserChrome.State, - isFavorite: Boolean, - consoleCount: Int, -): String = - when { - action == Action.BACK_TO_APP -> stringRes(CommonsR.string.browser_action_back_to_app, BrowserChrome.displayHost(state.startUrl)) - action == Action.CONSOLE && consoleCount > 0 -> stringRes(CommonsR.string.browser_console_title, consoleCount) - else -> stringRes(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite, torOn = state.torOn == true)) - } - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun SheetHeader( - chrome: EmbeddedTabChrome, - state: BrowserChrome.State, - editingAddress: Boolean, - onOriginTap: () -> Unit, - onCopy: () -> Unit, - onNavigate: (String) -> Unit, -) { - val security = BrowserChrome.security(state) - Row(Modifier.padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically) { - SymbolOrDrawable( - BrowserChromeLabels.securitySymbol(security), - BrowserChromeLabels.securityDrawable(security), - tint = if (state.isSandbox) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - size = 20, - ) - Spacer(Modifier.width(12.dp)) - if (editingAddress) { - AddressField(state.url, onNavigate, Modifier.weight(1f)) - } else { - Column( - Modifier - .weight(1f) - .clip(RoundedCornerShape(8.dp)) - .combinedClickable(onClick = onOriginTap, onLongClick = if (state.isSandbox) null else onCopy), - ) { - Text(chrome.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) - val securityLabel = stringRes(BrowserChromeLabels.securityLabel(security)) - Text( - if (state.isSandbox) securityLabel else BrowserChrome.displayHost(state.url) + " · " + securityLabel, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -/** The rarely used editable address, swapped in for the origin chip by "Edit address". */ -@Composable -private fun AddressField( - url: String, - onNavigate: (String) -> Unit, - modifier: Modifier = Modifier, -) { - var field by remember { mutableStateOf(TextFieldValue(url, TextRange(0, url.length))) } - val focus = remember { FocusRequester() } - LaunchedEffect(Unit) { focus.requestFocus() } - TextField( - value = field, - onValueChange = { field = it }, - modifier = modifier.focusRequester(focus), - singleLine = true, - placeholder = { Text(stringRes(CommonsR.string.browser_address_hint)) }, - keyboardOptions = - KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Uri, - imeAction = ImeAction.Go, - ), - keyboardActions = - KeyboardActions(onGo = { - field.text - .trim() - .takeIf { it.isNotEmpty() } - ?.let(onNavigate) - }), - colors = - TextFieldDefaults.colors( - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - ), - ) -} - -@Composable -private fun IconActionRow( - state: BrowserChrome.State, - isFavorite: Boolean, - onAction: (Action) -> Unit, -) { - Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), horizontalArrangement = Arrangement.SpaceEvenly) { - BrowserChrome.iconRow(state).forEach { action -> - val enabled = BrowserChrome.isEnabled(state, action) - val favorite = action == Action.FAVORITE && isFavorite - IconButton(onClick = { onAction(action) }, enabled = enabled) { - Icon( - BrowserChromeLabels.symbolFor(action)!!, - contentDescription = stringRes(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite)), - modifier = Modifier.size(24.dp), - tint = - when { - !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f) - favorite -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.onSurface - }, - filled = favorite, - ) - } - } - } -} - -@Composable -private fun SymbolOrDrawable( - symbol: MaterialSymbol?, - drawable: Int?, - tint: Color, - size: Int, -) { - if (drawable != null) { - Material3Icon(painterResource(drawable), contentDescription = null, modifier = Modifier.size(size.dp), tint = tint) - } else if (symbol != null) { - Icon(symbol, contentDescription = null, modifier = Modifier.size(size.dp), tint = tint) - } -} - -@Composable -private fun SheetItem( - action: Action, - label: String, - onClick: () -> Unit, -) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SymbolOrDrawable(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), MaterialTheme.colorScheme.onSurfaceVariant, 22) - Spacer(Modifier.width(14.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge) - } -} - -@Composable -private fun SheetSwitchItem( - action: Action, - label: String, - checked: Boolean, - onToggle: () -> Unit, -) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onToggle) - // Same row rhythm as [SheetItem] so every entry lines up; the Switch is taller but the - // padding (the inter-item spacing) is identical. - .padding(horizontal = 8.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - SymbolOrDrawable(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), MaterialTheme.colorScheme.onSurfaceVariant, 22) - Spacer(Modifier.width(14.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = { onToggle() }) - } -} - -@Composable -private fun TextSizeRow( - textZoom: Int, - onTextZoom: (Int) -> Unit, -) { - Row( - Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 2.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(MaterialSymbols.FormatSize, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(14.dp)) - Text(stringRes(CommonsR.string.browser_action_text_size), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - IconButton(onClick = { onTextZoom(BrowserChrome.stepTextZoom(textZoom, larger = false)) }) { - Icon(MaterialSymbols.Remove, contentDescription = stringRes(CommonsR.string.browser_action_text_smaller)) - } - Text( - stringRes(CommonsR.string.browser_action_text_size_value, textZoom), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.widthIn(min = 44.dp), - ) - IconButton(onClick = { onTextZoom(BrowserChrome.stepTextZoom(textZoom, larger = true)) }) { - Icon(MaterialSymbols.Add, contentDescription = stringRes(CommonsR.string.browser_action_text_larger)) - } - } -} 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 6a4a87eb83..a3b12d3d61 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 @@ -55,6 +55,8 @@ 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.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds @@ -174,21 +176,24 @@ private fun EmbeddedNostrAppTab( val chrome = remember(title, coordinate, isFavorite, controller) { EmbeddedTabChrome( - title = title.ifBlank { coordinate }, - state = - BrowserChrome.State( - surface = BrowserChrome.Surface.NAPPLET, - presentation = BrowserChrome.Presentation.EMBEDDED, - url = "", - startUrl = "", - hasAccessInfo = true, - // The embedded nsite/napplet host has no find or text-size plumbing (yet). - hasFind = false, - hasTextSize = false, + ui = + BrowserPillUi( + title = title.ifBlank { coordinate }, + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.NAPPLET, + presentation = BrowserChrome.Presentation.EMBEDDED, + url = "", + startUrl = "", + hasAccessInfo = true, + // The embedded nsite/napplet host has no find or text-size plumbing (yet). + hasFind = false, + hasTextSize = false, + ), + isFavorite = isFavorite, ), - isFavorite = isFavorite, - onAction = { action -> - when (action) { + onEvent = { event -> + 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) @@ -205,7 +210,6 @@ private fun EmbeddedNostrAppTab( else -> Unit } }, - onOriginTap = { showAccess = true }, ) } // Publish the top-sheet controls to the tab layer (drawn over the z-below surface). In a SideEffect diff --git a/commons/src/androidMain/res/values/strings.xml b/commons/src/androidMain/res/values/strings.xml index 6e673d6fda..2d9ffabb12 100644 --- a/commons/src/androidMain/res/values/strings.xml +++ b/commons/src/androidMain/res/values/strings.xml @@ -9,79 +9,22 @@ Choose a file No app available to pick a file - - Back - Forward - Reload - Stop loading - Add to favorites - Remove from favorites - Share… - Back to %1$s - Copy link - Edit address - Find in page - Text size - %1$d%% - Smaller text - Larger text - Desktop site - Add to Home screen - Open in another browser - Open full screen - Loads over Tor - Loads over the open web - What it can access - Site settings & permissions - Close - Privacy - Developer - Onion-routed over Tor - Secure connection - Not secure - Sandboxed app + Link copied No other browser installed - - Find in page - %1$d/%2$d - Previous match - Next match - Close find in page - - - %1$s says - This page says - Block dialogs from this page - Leave site? - Changes you made may not be saved. - Leave - Downloading %1$s… Saved %1$s to Downloads Couldn\'t download %1$s Share - - Page info - Your connection to this site is encrypted. - Your connection to this site is not encrypted. Don\'t enter passwords or payment details. - This site loads over Tor. The site can\'t see your IP address. - This site loads over the open web. The site can see your IP address. - Certificate issued to %1$s by %2$s, valid until %3$s. - Permissions - Clear site data Site data cleared - %1$s wants to Use your camera Use your microphone Know your approximate location - Allow - Block Amethyst doesn\'t have Android\'s permission for this. Allow it in system settings. Ask Allowed diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt index f46a97d35e..3175737aec 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt @@ -76,24 +76,29 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes /** * The address as a first-class input, opened from the origin field. A 56dp pill field with the URL * pre-selected, a leading search/security icon, and trailing clear + a filled Go; below it Paste and go - * (when the clipboard holds a URL) and the omnibox suggestions, each with a ↖ that fills its address in - * without leaving. [onCancel] folds it back into the origin field. + * (when the clipboard holds a URL) and the omnibox suggestions for what's typed ([suggestionsFor]), each + * with a ↖ that fills its address in without leaving. [onCancel] folds it back into the origin field. */ @Composable fun AddressEditor( initialUrl: String, security: BrowserChrome.Security, - suggestions: List, + suggestionsFor: (String) -> List, clipboardUrl: String?, onGo: (String) -> Unit, onCancel: () -> Unit, modifier: Modifier = Modifier, autoFocus: Boolean = true, + onPasteAndGo: (() -> Unit)? = null, ) { var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) } val focus = remember { FocusRequester() } if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + // Ranked against what the user typed; the untouched page URL counts as nothing typed yet. + val typed = if (field.text == initialUrl) "" else field.text + val suggestions = remember(typed) { suggestionsFor(typed) } + fun go(text: String = field.text) { text.trim().takeIf { it.isNotEmpty() }?.let(onGo) } @@ -153,10 +158,13 @@ fun AddressEditor( } } - if (clipboardUrl != null && clipboardUrl != field.text) { + // [clipboardUrl] when the host may read the clipboard up front; otherwise [onPasteAndGo] reads it only + // on tap (Android announces every clipboard read, so the offer can't peek at the contents). + if ((clipboardUrl != null && clipboardUrl != field.text) || onPasteAndGo != null) { + val pasteLabel = stringRes(Res.string.browser_pill_paste_go) AssistChip( - onClick = { go(clipboardUrl) }, - label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + onClick = { if (clipboardUrl != null) go(clipboardUrl) else onPasteAndGo?.invoke() }, + label = { Text(if (clipboardUrl != null) pasteLabel + " · " + BrowserChrome.displayHost(clipboardUrl) else pasteLabel, maxLines = 1, overflow = TextOverflow.Ellipsis) }, leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) }, modifier = Modifier.padding(start = 48.dp), ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserChromeTheme.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserChromeTheme.kt new file mode 100644 index 0000000000..b7875a0a48 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserChromeTheme.kt @@ -0,0 +1,35 @@ +/* + * 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.runtime.Composable +import com.vitorpamplona.amethyst.commons.ui.theme.AmethystPreviewTheme + +/** + * The Material theme for browser chrome drawn outside the main app's composition — the full-screen + * browser and nsite/napplet windows in the keyless `:napplet` process, which has no access to the user's + * theme preferences beyond light/dark. Uses Amethyst's default palette, typography and shapes. + */ +@Composable +fun BrowserChromeTheme( + dark: Boolean, + content: @Composable () -> Unit, +) = AmethystPreviewTheme(dark = dark, content = content) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt index e7bd6ca2a3..3c261ee149 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -109,10 +109,11 @@ fun BrowserPill( onEvent: (BrowserPillEvent) -> Unit, modifier: Modifier = Modifier, showClose: Boolean = false, - suggestions: List = emptyList(), + suggestionsFor: (String) -> List = { emptyList() }, clipboardUrl: String? = null, initiallyEditing: Boolean = false, initiallyTextSizeOpen: Boolean = false, + onPasteAndGo: (() -> Unit)? = null, ) { Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { AnimatedVisibility( @@ -128,10 +129,11 @@ fun BrowserPill( onEvent(event) }, showClose = showClose, - suggestions = suggestions, + suggestionsFor = suggestionsFor, clipboardUrl = clipboardUrl, initiallyEditing = initiallyEditing, initiallyTextSizeOpen = initiallyTextSizeOpen, + onPasteAndGo = onPasteAndGo, ) } PillHandle(ui, expanded, onExpandedChange) @@ -213,10 +215,11 @@ fun BrowserPillSheet( onEvent: (BrowserPillEvent) -> Unit, modifier: Modifier = Modifier, showClose: Boolean = false, - suggestions: List = emptyList(), + suggestionsFor: (String) -> List = { emptyList() }, clipboardUrl: String? = null, initiallyEditing: Boolean = false, initiallyTextSizeOpen: Boolean = false, + onPasteAndGo: (() -> Unit)? = null, ) { var editing by rememberSaveable { mutableStateOf(initiallyEditing) } var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) } @@ -244,10 +247,11 @@ fun BrowserPillSheet( AddressEditor( initialUrl = ui.chrome.url, security = ui.security, - suggestions = suggestions, + suggestionsFor = suggestionsFor, clipboardUrl = clipboardUrl, onGo = { onEvent(BrowserPillEvent.Navigate(it)) }, onCancel = { editing = false }, + onPasteAndGo = onPasteAndGo, ) } else { PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) }) @@ -255,6 +259,7 @@ fun BrowserPillSheet( ui = ui, onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null, onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) else null, + onSecurityTap = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.PageInfo) }) else null, ) if (Action.BACK_TO_APP in page) { OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) { diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt index b9c78e7e1f..1d3ea5b87a 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt @@ -67,6 +67,9 @@ sealed interface BrowserPillEvent { /** The origin field was long-pressed (copy link). */ data object CopyOrigin : BrowserPillEvent + /** The security badge at the start of the origin field was tapped: show page info. */ + data object PageInfo : BrowserPillEvent + data object Close : BrowserPillEvent } 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 fce006a7e9..ace3a1cf43 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 @@ -148,7 +148,7 @@ fun BrowserPillAddressEditorPreview() { expanded = true, onExpandedChange = {}, onEvent = {}, - suggestions = BrowserPillSamples.suggestions, + suggestionsFor = { BrowserPillSamples.suggestions }, clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", initiallyEditing = true, ) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt index 28de782946..c2f25cb6cc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt @@ -65,6 +65,7 @@ import androidx.compose.ui.unit.sp 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_close import com.vitorpamplona.amethyst.commons.resources.browser_pill_console import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear @@ -91,6 +92,7 @@ fun ConsoleSheet( modifier: Modifier = Modifier, maxHeight: Dp = 320.dp, initialFilter: ConsoleFilter = ConsoleFilter.ALL, + onClose: (() -> Unit)? = null, ) { var filter by remember { mutableStateOf(initialFilter) } val errors = lines.count { it.level == ConsoleLine.Level.ERROR } @@ -127,6 +129,11 @@ fun ConsoleSheet( IconButton(onClick = onClear, enabled = lines.isNotEmpty()) { Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp)) } + if (onClose != null) { + IconButton(onClick = onClose) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_close), modifier = Modifier.size(20.dp)) + } + } } Row( Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt index 1dc373369b..226af88eb5 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt @@ -123,6 +123,7 @@ fun OriginField( onEdit: (() -> Unit)?, onLongPress: (() -> Unit)?, modifier: Modifier = Modifier, + onSecurityTap: (() -> Unit)? = null, ) { val security = ui.security Row( @@ -136,11 +137,17 @@ fun OriginField( role = Role.Button, onClick = { onEdit?.invoke() }, onLongClick = onLongPress, - ).padding(start = 16.dp, end = 6.dp), + ).padding(start = if (onSecurityTap != null) 4.dp else 16.dp, end = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { - SecurityIcon(security, size = 18.dp) - Spacer(Modifier.width(10.dp)) + // Chrome's page-info entry point: the connection badge itself. + if (onSecurityTap != null) { + IconButton(onClick = onSecurityTap) { SecurityIcon(security, size = 20.dp) } + Spacer(Modifier.width(2.dp)) + } else { + SecurityIcon(security, size = 18.dp) + Spacer(Modifier.width(10.dp)) + } Column(Modifier.weight(1f).padding(vertical = 6.dp)) { Text( if (ui.chrome.isSandbox) stringRes(securityLabel(security)) else ui.host, diff --git a/nappletHost/build.gradle.kts b/nappletHost/build.gradle.kts index f10b0653c1..cfbaf52802 100644 --- a/nappletHost/build.gradle.kts +++ b/nappletHost/build.gradle.kts @@ -2,6 +2,8 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) + // The full-screen browser / napplet windows draw the shared Compose browser chrome (commonsUI). + alias(libs.plugins.jetbrainsComposeCompiler) } android { @@ -43,6 +45,11 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.activity) + implementation(libs.jetbrains.compose.ui) + implementation(libs.jetbrains.compose.foundation) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.compose.material3) + implementation(libs.jetbrains.compose.components.resources) implementation(libs.androidx.webkit) implementation(libs.okhttp) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt new file mode 100644 index 0000000000..5041339b99 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -0,0 +1,388 @@ +/* + * 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.napplethost + +import android.view.Gravity +import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.foundation.layout.widthIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.compose.ui.unit.dp +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.AddressSuggestion +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.FindInPagePill +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogCard +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageInfoSheet +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard + +/** + * The browser chrome of a full-screen `:napplet` window (the web browser and the nsite/napplet host), drawn + * with the shared Compose components from `commonsUI` — the same pill, find pill, console sheet and page + * cards the embedded tabs use, so both surfaces are pixel-identical. + * + * Owns the chrome's Compose state and two [ComposeView]s laid over the page: the pill at the top (grown to + * the full window while open, so a tap outside closes it) and find / console at the bottom. Page dialogs, + * permission prompts and page info open as Compose dialogs. The window only feeds state in ([ui], console + * lines, find results) and handles what comes out through [Listener]. + */ +class BrowserChromeHost( + private val activity: ComponentActivity, + private val dark: Boolean, + initial: BrowserPillUi, + private val listener: Listener, + private val showClose: Boolean = true, + private val suggestionsFor: (String) -> List = { emptyList() }, +) { + /** What the chrome asks its window to do. */ + interface Listener { + /** Everything from the pill except find and the console, which the host runs itself. */ + fun onPillEvent(event: BrowserPillEvent) + + fun onFind(query: String) {} + + fun onFindNext(forward: Boolean) {} + + fun onFindClosed() {} + + fun onPermissionChange( + permission: BrowserSitePermission, + decision: BrowserSitePermission.Decision, + ) {} + + fun onClearSiteData() {} + + /** The pill, find or the console opened or closed (the window may need to route back). */ + fun onPanelsChanged() {} + } + + /** A page's JS dialog waiting for an answer. */ + class PendingDialog( + val type: PageDialogType, + val host: String?, + val security: BrowserChrome.Security, + val message: String, + val defaultValue: String, + val offerBlock: Boolean, + val answer: (confirmed: Boolean, text: String?, block: Boolean) -> Unit, + ) + + /** A site permission request waiting for an answer: allow or not, and whether to remember the choice. */ + class PendingPermission( + val host: String, + val security: BrowserChrome.Security, + val permissions: Set, + val answer: (allow: Boolean, remember: Boolean) -> Unit, + ) + + var ui by mutableStateOf(initial) + var expanded by mutableStateOf(false) + private set + + var findOpen by mutableStateOf(false) + private set + private var findQuery by mutableStateOf("") + private var findActive by mutableStateOf(0) + private var findTotal by mutableStateOf(null) + + var consoleShowing by mutableStateOf(false) + private set + val console = mutableStateListOf() + + var dialog by mutableStateOf(null) + var permissionPrompt by mutableStateOf(null) + private var pageInfoOpen by mutableStateOf(false) + private var certificate by mutableStateOf(null) + + private var topView: ComposeView? = null + + /** Lays the chrome over [root], above the page. */ + fun attach(root: FrameLayout) { + val top = + ComposeView(activity).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { BrowserChromeTheme(dark) { TopChrome() } } + } + topView = top + root.addView(top, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.TOP)) + val bottom = + ComposeView(activity).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { BrowserChromeTheme(dark) { BottomChrome() } } + } + root.addView(bottom, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)) + } + + fun showPill(open: Boolean) { + if (expanded == open) return + expanded = open + // Open, the pill's view spans the window so a tap anywhere outside the sheet closes it; closed, it + // shrinks back to the grabber so every other touch reaches the page. + topView?.let { view -> + view.layoutParams = (view.layoutParams as FrameLayout.LayoutParams).apply { height = if (open) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT } + } + listener.onPanelsChanged() + } + + fun openFind() { + consoleShowing = false + findOpen = true + listener.onPanelsChanged() + } + + fun closeFind() { + if (!findOpen) return + findOpen = false + findQuery = "" + findTotal = null + listener.onFindClosed() + listener.onPanelsChanged() + } + + fun showConsole(show: Boolean) { + if (show) closeFind() + consoleShowing = show + listener.onPanelsChanged() + } + + fun setFindResult( + active: Int, + total: Int, + ) { + findActive = active + findTotal = total + } + + fun appendConsole(line: ConsoleLine) { + if (console.size >= MAX_CONSOLE_LINES) console.removeAt(0) + console.add(line) + } + + /** Page info for the page on screen, with its certificate when it has one. */ + fun showPageInfo(certificate: CertificateInfo?) { + this.certificate = certificate + pageInfoOpen = true + } + + /** Back closes, in order: the open pill, find, then nothing (the page's own history). */ + fun handleBack(): Boolean = + when { + expanded -> { + showPill(false) + true + } + findOpen -> { + closeFind() + true + } + else -> false + } + + val wantsBack: Boolean get() = expanded || findOpen + + private fun uiWithConsole(): BrowserPillUi = ui.copy(consoleShowing = consoleShowing, consoleErrors = console.count { it.level == ConsoleLine.Level.ERROR }) + + @Composable + private fun TopChrome() { + Box(Modifier.fillMaxSize()) { + if (expanded) { + Box( + Modifier + .fillMaxSize() + .clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) { showPill(false) }, + ) + } + BrowserPill( + ui = uiWithConsole(), + expanded = expanded, + onExpandedChange = ::showPill, + onEvent = { event -> + when { + event is BrowserPillEvent.Action && event.action == BrowserChrome.Action.FIND_IN_PAGE -> openFind() + event is BrowserPillEvent.Action && event.action == BrowserChrome.Action.CONSOLE -> showConsole(!consoleShowing) + else -> listener.onPillEvent(event) + } + }, + showClose = showClose, + suggestionsFor = suggestionsFor, + onPasteAndGo = if (clipboardHasText()) ({ pasteAndGo() }) else null, + modifier = Modifier.align(Alignment.TopCenter), + ) + } + PageDialogs() + } + + @Composable + private fun BottomChrome() { + when { + findOpen -> + FindInPagePill( + query = findQuery, + onQueryChange = { + findQuery = it + if (it.isEmpty()) findTotal = null + listener.onFind(it) + }, + active = findActive, + total = findTotal, + onNext = listener::onFindNext, + onClose = ::closeFind, + ) + consoleShowing -> + ConsoleSheet( + lines = console, + onCopy = { lines -> copy(lines.joinToString("\n") { formatLine(it) }) }, + onClear = { console.clear() }, + onCopyLine = { copy(formatLine(it)) }, + onClose = { showConsole(false) }, + ) + } + } + + @Composable + private fun PageDialogs() { + dialog?.let { pending -> + Dialog(onDismissRequest = { + dialog = null + pending.answer(false, null, false) + }) { + PageDialogCard( + type = pending.type, + host = pending.host, + security = pending.security, + message = pending.message, + defaultValue = pending.defaultValue, + offerBlock = pending.offerBlock, + onResult = { confirmed, text, block -> + dialog = null + pending.answer(confirmed, text, block) + }, + ) + } + } + permissionPrompt?.let { pending -> + Dialog(onDismissRequest = { + permissionPrompt = null + pending.answer(false, false) + }) { + PermissionPromptCard( + host = pending.host, + security = pending.security, + permissions = pending.permissions, + onAllow = { + permissionPrompt = null + pending.answer(true, true) + }, + onAllowOnce = { + permissionPrompt = null + pending.answer(true, false) + }, + onDeny = { + permissionPrompt = null + pending.answer(false, true) + }, + ) + } + } + if (pageInfoOpen) { + Dialog(onDismissRequest = { pageInfoOpen = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + PageInfoSheet( + ui = ui, + certificate = certificate, + onPermissionChange = { permission, decision -> + ui = ui.copy(sitePermissions = ui.sitePermissions + (permission to decision)) + listener.onPermissionChange(permission, decision) + }, + onClearSiteData = { + pageInfoOpen = false + listener.onClearSiteData() + }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } + } + } + + private fun clipboardHasText(): Boolean = BrowserWebTools.clipboardHasText(activity) + + private fun pasteAndGo() { + val text = BrowserWebTools.clipboardText(activity) + showPill(false) + if (text != null) listener.onPillEvent(BrowserPillEvent.Navigate(text)) + } + + private fun copy(text: String) = BrowserWebTools.copyText(activity, "console", text) + + private fun formatLine(line: ConsoleLine): String = + buildString { + append(line.level.name).append(": ").append(line.message) + if (line.source.isNotBlank()) { + append(" (") + .append(line.source) + .append(':') + .append(line.line) + .append(')') + } + } + + companion object { + private const val MAX_CONSOLE_LINES = 500 + + /** Maps WebView's console level onto the chrome's. */ + fun levelOf(level: ConsoleMessage.MessageLevel): ConsoleLine.Level = + when (level) { + ConsoleMessage.MessageLevel.ERROR -> ConsoleLine.Level.ERROR + ConsoleMessage.MessageLevel.WARNING -> ConsoleLine.Level.WARNING + ConsoleMessage.MessageLevel.DEBUG -> ConsoleLine.Level.DEBUG + ConsoleMessage.MessageLevel.TIP -> ConsoleLine.Level.INFO + else -> ConsoleLine.Level.LOG + } + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt deleted file mode 100644 index 088aac02a3..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.content.Context -import android.graphics.Typeface -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract -import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * How each [BrowserChrome.Action] looks: its Material Symbol (or, for Tor, the onion drawable) and its - * label. Shared by both renderers of the top pill — the Compose sheet over embedded tabs and the - * plain-View sheet in the full-screen browser — so an action has one icon and one name everywhere. - */ -object BrowserChromeLabels { - /** The glyph for [action], or null when it is drawn from [drawableFor] instead. */ - fun symbolFor(action: Action): MaterialSymbol? = - when (action) { - Action.BACK -> MaterialSymbols.AutoMirrored.ArrowBack - Action.FORWARD -> MaterialSymbols.AutoMirrored.ArrowForward - Action.RELOAD -> MaterialSymbols.Refresh - Action.STOP -> MaterialSymbols.Close - Action.FAVORITE -> MaterialSymbols.Star - Action.SHARE -> MaterialSymbols.Share - Action.BACK_TO_APP -> MaterialSymbols.Home - Action.COPY_LINK -> MaterialSymbols.ContentCopy - Action.EDIT_ADDRESS -> MaterialSymbols.Edit - Action.FIND_IN_PAGE -> MaterialSymbols.Search - Action.TEXT_SIZE -> MaterialSymbols.FormatSize - Action.DESKTOP_SITE -> MaterialSymbols.DesktopWindows - Action.ADD_TO_HOME_SCREEN -> MaterialSymbols.AddToHomeScreen - Action.OPEN_IN_BROWSER_APP -> MaterialSymbols.OpenInBrowser - Action.OPEN_FULL_SCREEN -> MaterialSymbols.OpenInFull - Action.TOR -> null - Action.ACCESS_INFO -> MaterialSymbols.Info - Action.SITE_SETTINGS -> MaterialSymbols.Tune - Action.CONSOLE -> MaterialSymbols.Code - } - - @DrawableRes - fun drawableFor(action: Action): Int? = if (action == Action.TOR) R.drawable.ic_tor else null - - /** The label for [action]; [isFavorite] and [torOn] pick the stateful wording. */ - @StringRes - fun labelFor( - action: Action, - isFavorite: Boolean = false, - torOn: Boolean = false, - ): Int = - when (action) { - Action.BACK -> CommonsR.string.browser_action_back - Action.FORWARD -> CommonsR.string.browser_action_forward - Action.RELOAD -> CommonsR.string.browser_action_reload - Action.STOP -> CommonsR.string.browser_action_stop - Action.FAVORITE -> if (isFavorite) CommonsR.string.browser_action_favorite_remove else CommonsR.string.browser_action_favorite_add - Action.SHARE -> CommonsR.string.browser_action_share - Action.BACK_TO_APP -> CommonsR.string.browser_action_back_to_app - Action.COPY_LINK -> CommonsR.string.browser_action_copy_link - Action.EDIT_ADDRESS -> CommonsR.string.browser_action_edit_address - Action.FIND_IN_PAGE -> CommonsR.string.browser_action_find_in_page - Action.TEXT_SIZE -> CommonsR.string.browser_action_text_size - Action.DESKTOP_SITE -> CommonsR.string.browser_action_desktop_site - Action.ADD_TO_HOME_SCREEN -> CommonsR.string.browser_action_add_to_home - Action.OPEN_IN_BROWSER_APP -> CommonsR.string.browser_action_open_in_browser_app - Action.OPEN_FULL_SCREEN -> CommonsR.string.browser_action_open_full_screen - Action.TOR -> if (torOn) CommonsR.string.browser_action_tor_on else CommonsR.string.browser_action_tor_off - Action.ACCESS_INFO -> CommonsR.string.browser_action_access_info - Action.SITE_SETTINGS -> CommonsR.string.browser_action_site_settings - Action.CONSOLE -> CommonsR.string.browser_console_title_short - } - - /** Actions whose row carries an on/off switch. */ - fun isToggle(action: Action): Boolean = action == Action.TOR || action == Action.DESKTOP_SITE || action == Action.CONSOLE - - /** Actions that keep the sheet open when used (the text-size stepper, the address editor). */ - fun keepsSheetOpen(action: Action): Boolean = action == Action.TEXT_SIZE || action == Action.EDIT_ADDRESS - - fun securitySymbol(security: Security): MaterialSymbol? = - when (security) { - Security.TOR -> null - Security.HTTPS -> MaterialSymbols.Lock - Security.HTTP -> MaterialSymbols.NoEncryption - Security.SANDBOX -> MaterialSymbols.Shield - } - - @DrawableRes - fun securityDrawable(security: Security): Int? = if (security == Security.TOR) R.drawable.ic_tor else null - - @StringRes - fun securityLabel(security: Security): Int = - when (security) { - Security.TOR -> CommonsR.string.browser_security_tor - Security.HTTPS -> CommonsR.string.browser_security_https - Security.HTTP -> CommonsR.string.browser_security_http - Security.SANDBOX -> CommonsR.string.browser_security_sandbox - } -} - -/** - * The Material Symbols font the Compose UI draws its icons from, loaded for plain Views. It ships inside - * `:commonsUI`'s compose resources (the same APK assets the napplet shim is read from), so the full-screen - * browser's controls use the very same glyphs as the embedded tab's. - */ -object BrowserGlyphs { - private const val FONT_PATH = "font/material_symbols_outlined.ttf" - - @Volatile private var cached: Typeface? = null - - @Volatile private var cachedFilled: Typeface? = null - - /** The same font with its FILL axis at 1 — a pinned page's solid star, as the Compose icon draws it. */ - fun filledTypeface(context: Context): Typeface = - cachedFilled ?: synchronized(this) { - cachedFilled ?: ( - runCatching { - Typeface - .Builder(context.assets, NappletWebContract.RESOURCE_ASSET_ROOT + FONT_PATH) - .setFontVariationSettings("'FILL' 1") - .build() - }.getOrNull() ?: typeface(context) - ).also { cachedFilled = it } - } - - fun typeface(context: Context): Typeface = - cached ?: synchronized(this) { - cached ?: runCatching { Typeface.createFromAsset(context.assets, NappletWebContract.RESOURCE_ASSET_ROOT + FONT_PATH) } - .onFailure { Log.w("BrowserGlyphs", "Material Symbols font missing from assets", it) } - .getOrDefault(Typeface.DEFAULT) - .also { cached = it } - } -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt deleted file mode 100644 index d96a24e118..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt +++ /dev/null @@ -1,197 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Color -import android.graphics.drawable.GradientDrawable -import android.text.Editable -import android.text.InputType -import android.text.TextWatcher -import android.util.TypedValue -import android.view.Gravity -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.webkit.WebView -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * The find-in-page bar for the full-screen surfaces, docked at the bottom edge (the top edge belongs to the - * pill's grabber, and the bottom keeps it off the page's own header). Drives [WebView.findAllAsync] / - * [WebView.findNext] on [webView] and shows "n/m". Hidden until [show]. - */ -@SuppressLint("ViewConstructor") -class BrowserFindBar( - context: Context, - private val webViewProvider: () -> WebView?, - private val onClosed: () -> Unit = {}, -) : LinearLayout(context) { - private val onSurface = color(android.R.attr.textColorPrimary) - private val dimmed = color(android.R.attr.textColorSecondary) - private val glyphs = BrowserGlyphs.typeface(context) - - private val field: EditText - private val count: TextView - - init { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - visibility = View.GONE - elevation = dp(8).toFloat() - background = - GradientDrawable().apply { - cornerRadii = floatArrayOf(dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), 0f, 0f, 0f, 0f) - setColor(color(android.R.attr.colorBackground)) - } - setPadding(dp(12), dp(4), dp(4), dp(4)) - field = - EditText(context).apply { - hint = context.getString(CommonsR.string.browser_find_hint) - setTextColor(onSurface) - setHintTextColor(dimmed) - background = null - isSingleLine = true - textSize = 15f - inputType = InputType.TYPE_CLASS_TEXT - imeOptions = EditorInfo.IME_ACTION_SEARCH - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - addTextChangedListener( - object : TextWatcher { - override fun beforeTextChanged( - s: CharSequence?, - start: Int, - count: Int, - after: Int, - ) = Unit - - override fun onTextChanged( - s: CharSequence?, - start: Int, - before: Int, - count: Int, - ) = Unit - - override fun afterTextChanged(s: Editable?) = search(s?.toString().orEmpty()) - }, - ) - setOnEditorActionListener { _, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_SEARCH) { - webViewProvider()?.findNext(true) - true - } else { - false - } - } - } - count = - TextView(context).apply { - setTextColor(dimmed) - textSize = 13f - setPadding(dp(8), 0, dp(4), 0) - } - addView(field) - addView(count) - addView(button(MaterialSymbols.KeyboardArrowUp, CommonsR.string.browser_find_previous) { webViewProvider()?.findNext(false) }) - addView(button(MaterialSymbols.KeyboardArrowDown, CommonsR.string.browser_find_next) { webViewProvider()?.findNext(true) }) - addView(button(MaterialSymbols.Close, CommonsR.string.browser_find_close) { hide() }) - } - - val isShowing: Boolean get() = visibility == View.VISIBLE - - fun show() { - webViewProvider()?.setFindListener { active, total, _ -> - count.text = - if (total > 0) { - context.getString(CommonsR.string.browser_find_count, active + 1, total) - } else if (field.text.isNullOrEmpty()) { - "" - } else { - "0/0" - } - } - visibility = View.VISIBLE - field.requestFocus() - field.selectAll() - context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0) - } - - fun hide() { - if (!isShowing) return - visibility = View.GONE - context.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(field.windowToken, 0) - webViewProvider()?.apply { - clearMatches() - setFindListener(null) - } - count.text = "" - onClosed() - } - - private fun search(query: String) { - val wv = webViewProvider() ?: return - if (query.isEmpty()) { - wv.clearMatches() - count.text = "" - } else { - wv.findAllAsync(query) - } - } - - private fun button( - symbol: MaterialSymbol, - label: Int, - onClick: () -> Unit, - ): View = - TextView(context).apply { - text = symbol.glyph - typeface = glyphs - setTextColor(onSurface) - setTextSize(TypedValue.COMPLEX_UNIT_DIP, 22f) - gravity = Gravity.CENTER - minWidth = dp(44) - minHeight = dp(44) - contentDescription = context.getString(label) - tooltipText = contentDescription - isClickable = true - background = - TypedValue().let { tv -> - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, tv, true) - ContextCompat.getDrawable(context, tv.resourceId) - } - setOnClickListener { onClick() } - } - - private fun color(attr: Int): Int { - val tv = TypedValue() - context.theme.resolveAttribute(attr, tv, true) - return if (tv.resourceId != 0) ContextCompat.getColor(context, tv.resourceId) else tv.data.takeIf { it != 0 } ?: Color.GRAY - } - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt deleted file mode 100644 index d2df5f3947..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt +++ /dev/null @@ -1,182 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.app.Activity -import android.app.AlertDialog -import android.text.InputType -import android.webkit.JsPromptResult -import android.webkit.JsResult -import android.widget.EditText -import android.widget.FrameLayout -import androidx.core.net.toUri -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * Shows a web page's `alert()` / `confirm()` / `prompt()` / `beforeunload` dialogs for the full-screen - * browser, the way Chrome does: titled with the page's origin ("example.com says") so a page can't pass - * its dialog off as Amethyst's own, and — from the second dialog on the same page — offering "Block - * dialogs from this page", so a script looping on alert() can't trap the user. The block lasts until the - * next main-frame navigation ([onMainFrameNavigation]). - * - * Every [JsResult] handed in is answered exactly once on every path (button, back, dismissal, a dialog - * already showing, the Activity going away): until it is, the page's JavaScript stays blocked. - */ -internal class BrowserJsDialogs( - private val activity: Activity, -) { - private var showing: AlertDialog? = null - private var shownOnThisPage = 0 - private var blocked = false - - /** A new main-frame load: the page that earned the block is gone. */ - fun onMainFrameNavigation() { - shownOnThisPage = 0 - blocked = false - } - - /** Closes a dialog still up (the Activity is being destroyed); its result is cancelled. */ - fun dismiss() { - showing?.dismiss() - showing = null - } - - fun alert( - url: String?, - message: String?, - result: JsResult, - ): Boolean = - show(result, onBlocked = { result.cancel() }) { answer -> - setTitle(titleFor(url)) - setMessage(message.orEmpty()) - setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm() } } - } - - fun confirm( - url: String?, - message: String?, - result: JsResult, - ): Boolean = - show(result, onBlocked = { result.cancel() }) { answer -> - setTitle(titleFor(url)) - setMessage(message.orEmpty()) - setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm() } } - setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } } - } - - fun prompt( - url: String?, - message: String?, - defaultValue: String?, - result: JsPromptResult, - ): Boolean { - val field = - EditText(activity).apply { - setText(defaultValue.orEmpty()) - setSelectAllOnFocus(true) - isSingleLine = true - inputType = InputType.TYPE_CLASS_TEXT - } - val container = - FrameLayout(activity).apply { - val pad = (20 * activity.resources.displayMetrics.density).toInt() - setPadding(pad, 0, pad, 0) - addView(field) - } - return show(result, onBlocked = { result.cancel() }) { answer -> - setTitle(titleFor(url)) - setMessage(message.orEmpty()) - setView(container) - setPositiveButton(android.R.string.ok) { _, _ -> answer { result.confirm(field.text?.toString().orEmpty()) } } - setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } } - } - } - - /** - * "Leave site?" — confirm lets the navigation proceed, cancel keeps the user on the page. The page's own - * message is ignored, as in every current browser (it was abused for scare text). A blocked page may - * no longer hold the user hostage, so blocked means "leave". - */ - fun beforeUnload(result: JsResult): Boolean = - show(result, onBlocked = { result.confirm() }) { answer -> - setTitle(CommonsR.string.browser_js_leave_title) - setMessage(CommonsR.string.browser_js_leave_message) - setPositiveButton(CommonsR.string.browser_js_leave) { _, _ -> answer { result.confirm() } } - setNegativeButton(android.R.string.cancel) { _, _ -> answer { result.cancel() } } - } - - /** - * Builds and shows one dialog. [build] wires the buttons through `answer`, which records that the - * result was settled so the dismiss listener doesn't cancel it a second time. Always returns true: the - * result is ours from here on. - */ - private fun show( - result: JsResult, - onBlocked: () -> Unit, - build: AlertDialog.Builder.(answer: (() -> Unit) -> Unit) -> Unit, - ): Boolean { - if (blocked) { - onBlocked() - return true - } - // One at a time, and never onto a window that is going away. - if (showing != null || activity.isFinishing || activity.isDestroyed) { - result.cancel() - return true - } - var settled = false - val answer: (() -> Unit) -> Unit = { action -> - if (!settled) { - settled = true - action() - } - } - shownOnThisPage++ - val builder = AlertDialog.Builder(activity).apply { build(answer) } - if (shownOnThisPage > 1) { - builder.setNeutralButton(CommonsR.string.browser_js_dialog_block) { _, _ -> - blocked = true - answer { onBlocked() } - } - } - val dialog = - builder - .setOnDismissListener { - showing = null - // Back, a tap outside, or teardown: no button answered, so the page gets a cancel. - answer { result.cancel() } - }.create() - showing = dialog - dialog.show() - return true - } - - private fun titleFor(url: String?): String { - val uri = url?.let { runCatching { it.toUri() }.getOrNull() } - val scheme = uri?.scheme?.lowercase() - val host = uri?.host?.takeIf { it.isNotBlank() } - return if (host != null && (scheme == "http" || scheme == "https")) { - activity.getString(CommonsR.string.browser_js_dialog_title, host) - } else { - activity.getString(CommonsR.string.browser_js_dialog_title_generic) - } - } -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt index 127bedaeda..7a31abd958 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt @@ -23,12 +23,12 @@ package com.vitorpamplona.amethyst.napplethost import android.app.Activity import android.content.ActivityNotFoundException import android.content.ClipData +import android.content.ClipDescription import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Intent import android.net.Uri -import android.net.http.SslCertificate import android.os.Build import android.os.SystemClock import android.webkit.CookieManager @@ -40,6 +40,7 @@ import androidx.core.net.toUri import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.ui.pill.CertificateInfo import com.vitorpamplona.quartz.utils.Log import java.net.URISyntaxException import java.text.DateFormat @@ -266,37 +267,48 @@ object BrowserWebTools { // ---- page info ---- - /** The paragraphs of the page-info sheet for the page in [webView]. */ - fun pageInfo( - context: Context, - webView: WebView, - torOn: Boolean?, - ): String { - val url = webView.url.orEmpty() - val lines = mutableListOf() - lines += - context.getString( - if (url.startsWith("https://", ignoreCase = true)) CommonsR.string.browser_page_info_https else CommonsR.string.browser_page_info_http, + /** The certificate of the page in [webView], for page info; null for a page without one. */ + fun certificateInfo(webView: WebView): CertificateInfo? = + webView.certificate?.let { cert -> + CertificateInfo( + issuedTo = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty(), + issuedBy = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty(), + validUntil = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty(), ) - if (torOn != null) { - lines += context.getString(if (torOn) CommonsR.string.browser_page_info_tor else CommonsR.string.browser_page_info_open_web) } - webView.certificate?.let { lines += certificateLine(context, it) } - return lines.joinToString("\n\n") - } - - private fun certificateLine( - context: Context, - cert: SslCertificate, - ): String { - val to = cert.issuedTo?.cName?.takeIf { it.isNotBlank() } ?: cert.issuedTo?.oName.orEmpty() - val by = cert.issuedBy?.oName?.takeIf { it.isNotBlank() } ?: cert.issuedBy?.cName.orEmpty() - val until = cert.validNotAfterDate?.let { DateFormat.getDateInstance(DateFormat.MEDIUM).format(it) }.orEmpty() - return context.getString(CommonsR.string.browser_page_info_certificate, to, by, until) - } // ---- copy / share / other browser ---- + /** Copies [text] with no confirmation of our own (Android 13+ shows one). */ + fun copyText( + context: Context, + label: String, + text: String, + ) { + context.getSystemService(ClipboardManager::class.java)?.setPrimaryClip(ClipData.newPlainText(label, text)) + } + + /** + * Whether "Paste and go" can be offered. Checks the clip's type only, never its contents, so Android + * doesn't toast a clipboard read every time the pill opens. + */ + fun clipboardHasText(context: Context): Boolean { + val description = context.getSystemService(ClipboardManager::class.java)?.primaryClipDescription ?: return false + return description.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN) || description.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML) + } + + /** The clipboard's text, trimmed; read only when the user asks to paste. */ + fun clipboardText(context: Context): String? = + context + .getSystemService(ClipboardManager::class.java) + ?.primaryClip + ?.takeIf { it.itemCount > 0 } + ?.getItemAt(0) + ?.coerceToText(context) + ?.toString() + ?.trim() + ?.takeIf { it.isNotEmpty() } + fun copyToClipboard( context: Context, text: String, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index 13b8c3069d..da634a5ecf 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.napplethost import android.Manifest import android.app.ActivityManager -import android.app.AlertDialog import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager import android.content.res.ColorStateList +import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.Icon @@ -85,6 +85,10 @@ import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine +import com.vitorpamplona.amethyst.commons.browser.ui.pill.PageDialogType import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull import com.vitorpamplona.amethyst.commons.util.stringOrNull @@ -108,7 +112,7 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR * * PWA behaviours, beyond the page itself: its own task in Recents titled, iconed and coloured after the * site ([updateTaskDescription]); system bars tinted with the page's `theme-color`; the top pill - * ([NappletControlSheet], laid out by [BrowserChrome]); JS dialogs; new windows (`_blank` / `window.open`) + * ([BrowserChromeHost], laid out by [BrowserChrome]); JS dialogs; new windows (`_blank` / `window.open`) * as new browser windows with `opener` intact ([BrowserPopups]); downloads; HTML fullscreen video; * camera / microphone / location behind a per-site prompt; find in page; long-press link and image menus; * Web Share; and recovery from a renderer crash. @@ -130,10 +134,9 @@ class NappletBrowserActivity : ComponentActivity() { private var loadingView: View? = null private var crashView: View? = null private var resumed = false - private var controlSheet: NappletControlSheet? = null - private var consolePanel: NappletConsolePanel? = null - private var findBar: BrowserFindBar? = null - private var consoleShowing = false + + // The pill, find, console and page dialogs — the shared Compose chrome (see BrowserChromeHost). + private var chrome: BrowserChromeHost? = null // A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome // client's onProgressChanged; hidden at 100%. @@ -161,8 +164,10 @@ class NappletBrowserActivity : ComponentActivity() { private var customView: View? = null private var customViewCallback: WebChromeClient.CustomViewCallback? = null - // Page-originated alert/confirm/prompt/beforeunload, labelled with the page's origin. - private val jsDialogs by lazy { BrowserJsDialogs(this) } + // Page-originated alert/confirm/prompt/beforeunload: from the second dialog on a page, the user may block + // the rest until the next main-frame navigation (Chrome's rule), so a looping alert() can't trap them. + private var jsDialogsOnPage = 0 + private var jsDialogsBlocked = false // ---- HTML file input (``) ---- // Registered as a field so it is in place before onCreate returns, which is what @@ -175,7 +180,6 @@ class NappletBrowserActivity : ComponentActivity() { // ---- site permissions (camera / microphone / location) ---- private val sitePermissionQueries = mutableMapOf) -> Unit>() private var sitePermissionSeq = 0L - private var permissionPrompt: AlertDialog? = null private var pendingRuntimeGrant: ((Map) -> Unit)? = null private val runtimePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> @@ -230,7 +234,7 @@ class NappletBrowserActivity : ComponentActivity() { val wv = webView when { customView != null -> exitFullscreen() - findBar?.isShowing == true -> findBar?.hide() + chrome?.handleBack() == true -> Unit wv != null && wv.canGoBack() -> wv.goBack() else -> { isEnabled = false @@ -242,7 +246,7 @@ class NappletBrowserActivity : ComponentActivity() { } private fun syncBackState() { - backCallback.isEnabled = customView != null || findBar?.isShowing == true || webView?.canGoBack() == true + backCallback.isEnabled = customView != null || chrome?.wantsBack == true || webView?.canGoBack() == true } private val brokerConnection = @@ -304,18 +308,15 @@ class NappletBrowserActivity : ComponentActivity() { bindService(Intent().setClassName(this, NappletHostContract.BROKER_SERVICE_CLASS), brokerConnection, BIND_AUTO_CREATE) onBackPressedDispatcher.addCallback(this, backCallback) - val findBar = BrowserFindBar(this, { webView }) { syncBackState() }.also { this.findBar = it } val root = FrameLayout(this).apply { setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - addView(buildControlSheet(), FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.TOP)) - addView(buildConsolePanel(), FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)) - addView(findBar, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM)) - // Added last so the thin loading bar paints above the content (and over the grabber's top edge). - addView(topProgressBar) } this.root = root + chrome = buildChrome().also { it.attach(root) } + // Added last so the thin loading bar paints above the content (and over the grabber's top edge). + root.addView(topProgressBar) setContentView(root) // Pad by the system bars + cutout AND the IME: on an edge-to-edge window (enforced for targetSdk // 35+ on Android 15+) windowSoftInputMode=adjustResize no longer shrinks the window, so without @@ -355,6 +356,7 @@ class NappletBrowserActivity : ComponentActivity() { BrowserWebTools.applyBrowserSettings(wv) wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() + wv.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1) } @@ -415,8 +417,8 @@ class NappletBrowserActivity : ComponentActivity() { pendingFileChooser.cancel() fileChooserLauncher.teardown() // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. - jsDialogs.dismiss() - permissionPrompt?.dismiss() + chrome?.dialog?.answer?.invoke(false, null, false) + chrome?.permissionPrompt?.answer?.invoke(false, false) customViewCallback?.onCustomViewHidden() destroyWebView() super.onDestroy() @@ -466,7 +468,19 @@ class NappletBrowserActivity : ComponentActivity() { runCatching { broker.send(msg) } } - private fun currentUrl(): String = webView?.url?.takeIf { it.isNotBlank() } ?: controlSheet?.state?.url?.takeIf { it.isNotBlank() } ?: startUrl + private fun currentUrl(): String = + webView?.url?.takeIf { it.isNotBlank() } ?: chrome + ?.ui + ?.chrome + ?.url + ?.takeIf { it.isNotBlank() } ?: startUrl + + /** Updates what the chrome shows. */ + private inline fun updateUi(block: BrowserPillUi.() -> BrowserPillUi) { + chrome?.let { it.ui = it.ui.block() } + } + + private inline fun updateChromeState(crossinline block: BrowserChrome.State.() -> BrowserChrome.State) = updateUi { copy(chrome = chrome.block()) } /** Captures favicon, title and console output, and hosts every page-initiated UI. */ private inner class BrowserChromeClient : WebChromeClient() { @@ -493,7 +507,7 @@ class NappletBrowserActivity : ComponentActivity() { title: String?, ) { pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url } - controlSheet?.updateTitle(title) + updateUi { copy(title = pageTitle ?: BrowserChrome.displayHost(chrome.url)) } updateTaskDescription() } @@ -512,34 +526,30 @@ class NappletBrowserActivity : ComponentActivity() { } override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { - val panel = consolePanel ?: return false - panel.appendLog( - consoleMessage.messageLevel(), - consoleMessage.message(), - consoleMessage.sourceId(), - consoleMessage.lineNumber(), + val host = chrome ?: return false + host.appendConsole( + ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()), ) - controlSheet?.updateConsoleCount(panel.entryCount) return true } // The framework's own JS dialogs only appear when the WebView's context IS an Activity // (`JsDialogHelper.canShowAlertDialog`), and this one is built from [nightThemedContext] — a // configuration context, not the Activity — so without these overrides every alert() was silently - // dismissed, confirm() always answered false and prompt() null. [jsDialogs] shows them itself. + // dismissed, confirm() always answered false and prompt() null. The chrome shows them itself. override fun onJsAlert( view: WebView, url: String?, message: String?, result: JsResult, - ): Boolean = jsDialogs.alert(url, message, result) + ): Boolean = showJsDialog(PageDialogType.ALERT, url, message, null, result) override fun onJsConfirm( view: WebView, url: String?, message: String?, result: JsResult, - ): Boolean = jsDialogs.confirm(url, message, result) + ): Boolean = showJsDialog(PageDialogType.CONFIRM, url, message, null, result) override fun onJsPrompt( view: WebView, @@ -547,14 +557,14 @@ class NappletBrowserActivity : ComponentActivity() { message: String?, defaultValue: String?, result: JsPromptResult, - ): Boolean = jsDialogs.prompt(url, message, defaultValue, result) + ): Boolean = showJsDialog(PageDialogType.PROMPT, url, message, defaultValue, result) override fun onJsBeforeUnload( view: WebView, url: String?, message: String?, result: JsResult, - ): Boolean = jsDialogs.beforeUnload(result) + ): Boolean = showJsDialog(PageDialogType.BEFORE_UNLOAD, url, message, null, result) /** * A `_blank` link or a user-initiated `window.open()`: open it as a new browser window, like a new @@ -583,7 +593,7 @@ class NappletBrowserActivity : ComponentActivity() { override fun onPermissionRequest(request: PermissionRequest) = handlePermissionRequest(request) override fun onPermissionRequestCanceled(request: PermissionRequest) { - permissionPrompt?.dismiss() + chrome?.permissionPrompt = null } override fun onGeolocationPermissionsShowPrompt( @@ -598,7 +608,7 @@ class NappletBrowserActivity : ComponentActivity() { } override fun onGeolocationPermissionsHidePrompt() { - permissionPrompt?.dismiss() + chrome?.permissionPrompt = null } override fun onShowCustomView( @@ -659,8 +669,9 @@ class NappletBrowserActivity : ComponentActivity() { applyThemeColor(null) } // Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it. - jsDialogs.onMainFrameNavigation() - controlSheet?.setLoading(true) + jsDialogsOnPage = 0 + jsDialogsBlocked = false + updateChromeState { copy(isLoading = true) } showUrl(url) } @@ -707,7 +718,7 @@ class NappletBrowserActivity : ComponentActivity() { url: String, ) { syncNavigation(view) - controlSheet?.setLoading(false) + updateChromeState { copy(isLoading = false) } showUrl(url) // Record only a clean http(s) main-frame load — never a typed-but-failed address. if (!mainFrameLoadFailed && (url.startsWith("https://") || url.startsWith("http://"))) { @@ -741,7 +752,7 @@ class NappletBrowserActivity : ComponentActivity() { private fun syncNavigation(view: WebView) { syncBackState() - controlSheet?.setNavigation(view.canGoBack(), view.canGoForward()) + updateChromeState { copy(canGoBack = view.canGoBack(), canGoForward = view.canGoForward()) } } /** @@ -749,7 +760,16 @@ class NappletBrowserActivity : ComponentActivity() { * it is pinned — the registry lives in the main process, so the star can't know on its own. */ private fun showUrl(url: String) { - controlSheet?.updateUrl(url) + updateUi { + if (url == chrome.url) { + this + } else { + // Another site: its host names it until its title arrives; its pin state is unknown until the + // broker answers (the star toggle sends an explicit target, so a tap meanwhile can only add). + val newSite = BrowserChrome.displayHost(url) != BrowserChrome.displayHost(chrome.url) + copy(chrome = chrome.copy(url = url), title = if (newSite) BrowserChrome.displayHost(url) else title, isFavorite = false) + } + } if (url == lastFavoriteQueryUrl) return lastFavoriteQueryUrl = url val msg = @@ -835,7 +855,7 @@ class NappletBrowserActivity : ComponentActivity() { if (resolved.forceTor && proxyPort > 0 && !useTor) { useTor = true applyWebViewProxy(proxyPort) - controlSheet?.setTor(true) + updateChromeState { copy(torOn = true) } } webView?.loadUrl(resolved.url) } @@ -944,7 +964,8 @@ class NappletBrowserActivity : ComponentActivity() { } NappletIpc.MSG_WEB_FAVORITE_STATE -> { val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true - controlSheet?.setFavorite(url, data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, false)) + val favorite = data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, false) + updateUi { if (url == chrome.url) copy(isFavorite = favorite) else this } } NappletIpc.MSG_SITE_PERMISSIONS -> { val callback = sitePermissionQueries.remove(data.getLong(NappletIpc.KEY_REQUEST_ID)) ?: return true @@ -1004,10 +1025,10 @@ class NappletBrowserActivity : ComponentActivity() { if (ask.isEmpty()) { ensureRuntimePermissions(allowed, done) } else { - showPermissionPrompt(origin, ask) { allow -> - // null = dismissed without an answer: deny this once, remember nothing. - if (allow != null) ask.forEach { rememberSitePermission(origin, it, if (allow) Decision.ALLOW else Decision.BLOCK) } - ensureRuntimePermissions(if (allow == true) allowed + ask else allowed, done) + showPermissionPrompt(origin, ask) { remembered, grantNow -> + // remembered: true/false = Allow while visiting / Don't allow; null = only this time or dismissed. + if (remembered != null) ask.forEach { rememberSitePermission(origin, it, if (remembered) Decision.ALLOW else Decision.BLOCK) } + ensureRuntimePermissions(if (grantNow) allowed + ask else allowed, done) } } } @@ -1049,49 +1070,33 @@ class NappletBrowserActivity : ComponentActivity() { } /** - * Chrome's permission bubble: " wants to — Use your camera — Block / Allow". [answer] gets true - * (allow), false (block), or null when the prompt went away unanswered. + * The permission prompt ([com.vitorpamplona.amethyst.commons.browser.ui.pill.PermissionPromptCard]): + * [answer] gets true (allow, remembered), false (block, remembered), or null (allow only this time, or + * dismissed — nothing remembered; a dismissal also denies). */ private fun showPermissionPrompt( origin: String, permissions: List, - answer: (Boolean?) -> Unit, + answer: (allow: Boolean?, grantNow: Boolean) -> Unit, ) { - if (isFinishing || isDestroyed || permissionPrompt != null) { - answer(null) + val host = chrome + if (host == null || isFinishing || isDestroyed || host.permissionPrompt != null) { + answer(null, false) return } - var answered = false - val lines = - permissions.joinToString("\n") { - "• " + - getString( - when (it) { - BrowserSitePermission.CAMERA -> CommonsR.string.browser_permission_camera - BrowserSitePermission.MICROPHONE -> CommonsR.string.browser_permission_microphone - BrowserSitePermission.LOCATION -> CommonsR.string.browser_permission_location - }, - ) + host.permissionPrompt = + BrowserChromeHost.PendingPermission( + host = BrowserChrome.displayHost(origin), + security = BrowserChrome.security(host.ui.chrome), + permissions = permissions.toSet(), + ) { allow, remember -> + when { + allow && remember -> answer(true, true) + allow -> answer(null, true) + remember -> answer(false, false) + else -> answer(null, false) + } } - permissionPrompt = - AlertDialog - .Builder(this) - .setTitle(getString(CommonsR.string.browser_permission_title, BrowserChrome.displayHost(origin))) - .setMessage(lines) - .setPositiveButton(CommonsR.string.browser_permission_allow) { _, _ -> - answered = true - answer(true) - }.setNegativeButton(CommonsR.string.browser_permission_block) { _, _ -> - answered = true - answer(false) - }.setOnDismissListener { - permissionPrompt = null - // Dismissed without choosing (back, the page cancelling): deny this time, remember nothing. - if (!answered) { - answered = true - answer(null) - } - }.show() } /** Requests Android's runtime permission for each allowed site permission that lacks it. */ @@ -1283,7 +1288,7 @@ class NappletBrowserActivity : ComponentActivity() { useTor = newUseTor applyWebViewProxy(if (useTor) proxyPort else -1) webView?.reload() - controlSheet?.setTor(useTor) + updateChromeState { copy(torOn = useTor) } // Key the persisted choice on the host actually displayed (which may differ from startUrl after // in-page navigation), so the preference sticks to the right site. val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return @@ -1305,92 +1310,162 @@ class NappletBrowserActivity : ComponentActivity() { private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } /** - * The top pull-down pill: a small grabber at the top edge (out of the corner where a site shows its - * own avatar) that expands to the Chrome-PWA-style menu laid out by [BrowserChrome]. + * The window's chrome: the pull-down pill at the top (out of the corner where a site shows its own avatar), + * find and the console at the bottom, and the page's dialogs — the shared Compose components. */ - private fun buildControlSheet(): View = - NappletControlSheet( - context = this, - initialState = - BrowserChrome.State( - surface = BrowserChrome.Surface.WEB, - presentation = BrowserChrome.Presentation.FULL_SCREEN, - url = startUrl, - startUrl = startUrl, - torOn = if (proxyPort > 0) useTor else null, + private fun buildChrome(): BrowserChromeHost = + BrowserChromeHost( + activity = this, + dark = isDarkTheme(), + initial = + BrowserPillUi( + title = barTitle(), + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = startUrl, + startUrl = startUrl, + torOn = if (proxyPort > 0) useTor else null, + ), + isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), ), - title = barTitle(), - listener = sheetListener, - isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), - ).also { controlSheet = it } + listener = chromeListener, + ) - private val sheetListener = - object : NappletControlSheet.Listener { - override fun onAction(action: Action) { - val wv = webView - when (action) { - Action.BACK -> wv?.goBack() - Action.FORWARD -> wv?.goForward() - Action.RELOAD -> wv?.reload() - Action.STOP -> wv?.stopLoading() - Action.FAVORITE -> sendFavoriteToggle(currentUrl(), controlSheet?.wantsFavorite() ?: true) - Action.SHARE -> BrowserWebTools.share(this@NappletBrowserActivity, pageTitle, null, currentUrl()) - Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) } - Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl()) - Action.EDIT_ADDRESS -> Unit - Action.FIND_IN_PAGE -> { - setConsoleShowing(false) - findBar?.show() - syncBackState() + private fun isDarkTheme(): Boolean = + when (themeType) { + "DARK" -> true + "LIGHT" -> false + else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + } + + private val chromeListener = + object : BrowserChromeHost.Listener { + override fun onPillEvent(event: BrowserPillEvent) { + when (event) { + is BrowserPillEvent.Action -> onAction(event.action) + is BrowserPillEvent.Navigate -> loadAddress(event.input) + is BrowserPillEvent.TextZoom -> { + webView?.let { BrowserWebTools.setTextZoom(it, event.percent) } + updateUi { copy(textZoom = event.percent) } } - Action.TEXT_SIZE -> Unit - Action.DESKTOP_SITE -> - wv?.let { - val desktop = !BrowserWebTools.isDesktopMode(it) - BrowserWebTools.setDesktopMode(it, desktop) - controlSheet?.setDesktopSite(desktop) - } - Action.ADD_TO_HOME_SCREEN -> addToHomeScreen() - Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this@NappletBrowserActivity, currentUrl()) - Action.OPEN_FULL_SCREEN -> Unit - Action.TOR -> setNetworkMode(!useTor) - Action.ACCESS_INFO -> Unit - Action.SITE_SETTINGS -> openPermissions() - Action.CONSOLE -> setConsoleShowing(!consoleShowing) + BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl()) + BrowserPillEvent.PageInfo -> showPageInfo() + BrowserPillEvent.Close -> finish() } } - override fun onNavigate(text: String) = loadAddress(text) - - override fun onTextZoom(percent: Int) { - webView?.let { BrowserWebTools.setTextZoom(it, percent) } + override fun onFind(query: String) { + val wv = webView ?: return + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) } - override fun onOriginTap() = showPageInfo() + override fun onFindNext(forward: Boolean) { + webView?.findNext(forward) + } - override fun onClose() = finish() + override fun onFindClosed() { + webView?.clearMatches() + } + + override fun onPermissionChange( + permission: BrowserSitePermission, + decision: Decision, + ) { + BrowserChrome.originOf(currentUrl())?.let { rememberSitePermission(it, permission, decision) } + } + + override fun onClearSiteData() { + webView?.let { BrowserWebTools.clearSiteData(this@NappletBrowserActivity, it, currentUrl()) } + } + + override fun onPanelsChanged() = syncBackState() } - private fun setConsoleShowing(showing: Boolean) { - consoleShowing = showing - if (showing) findBar?.hide() - consolePanel?.setShowing(showing) - controlSheet?.setConsoleShowing(showing) + private fun onAction(action: Action) { + val wv = webView + when (action) { + Action.BACK -> wv?.goBack() + Action.FORWARD -> wv?.goForward() + Action.RELOAD -> wv?.reload() + Action.STOP -> wv?.stopLoading() + // The star flips the shown state; the toggle sends that target explicitly. + Action.FAVORITE -> sendFavoriteToggle(currentUrl(), chrome?.ui?.isFavorite != true) + Action.SHARE -> BrowserWebTools.share(this, pageTitle, null, currentUrl()) + Action.BACK_TO_APP -> wv?.let { BrowserWebTools.backToScope(it, startUrl) } + Action.COPY_LINK -> BrowserWebTools.copyToClipboard(this, currentUrl()) + Action.DESKTOP_SITE -> + wv?.let { + val desktop = !BrowserWebTools.isDesktopMode(it) + BrowserWebTools.setDesktopMode(it, desktop) + updateUi { copy(desktopSite = desktop) } + } + Action.ADD_TO_HOME_SCREEN -> addToHomeScreen() + Action.OPEN_IN_BROWSER_APP -> BrowserWebTools.openInOtherBrowser(this, currentUrl()) + Action.TOR -> setNetworkMode(!useTor) + Action.SITE_SETTINGS -> openPermissions() + else -> Unit + } } - /** Chrome's page-info sheet: connection, Tor, certificate, then site settings and clearing its data. */ + /** + * Shows a page's JS dialog in the chrome, titled with the page's host. Answers at once when dialogs are + * blocked for this page, another is already up, or the window is going away. + */ + private fun showJsDialog( + type: PageDialogType, + url: String?, + message: String?, + defaultValue: String?, + result: JsResult, + ): Boolean { + val host = chrome + if (jsDialogsBlocked) { + // A blocked page may no longer hold the user on it: leaving is allowed, everything else cancels. + if (type == PageDialogType.BEFORE_UNLOAD) result.confirm() else result.cancel() + return true + } + if (host == null || host.dialog != null || isFinishing || isDestroyed) { + result.cancel() + return true + } + jsDialogsOnPage++ + host.dialog = + BrowserChromeHost.PendingDialog( + type = type, + host = url?.let(BrowserChrome::originOf)?.let(BrowserChrome::displayHost), + security = BrowserChrome.security(host.ui.chrome), + message = message.orEmpty(), + defaultValue = defaultValue.orEmpty(), + offerBlock = jsDialogsOnPage > 1, + ) { confirmed, text, block -> + if (block) jsDialogsBlocked = true + when { + !confirmed -> result.cancel() + result is JsPromptResult -> result.confirm(text.orEmpty()) + else -> result.confirm() + } + } + return true + } + + /** + * Chrome's page info: connection, route and certificate, then this site's camera / microphone / location + * answers (asked of the broker first, so the sheet shows the truth) and its data. + */ private fun showPageInfo() { val wv = webView ?: return - val url = currentUrl() - AlertDialog - .Builder(this) - .setTitle(BrowserChrome.displayHost(url)) - .setMessage(BrowserWebTools.pageInfo(this, wv, if (proxyPort > 0) useTor else null)) - .setPositiveButton(android.R.string.ok, null) - .setNeutralButton(CommonsR.string.browser_page_info_permissions) { _, _ -> openPermissions() } - .setNegativeButton(CommonsR.string.browser_page_info_clear_data) { _, _ -> - webView?.let { BrowserWebTools.clearSiteData(this, it, url) } - }.show() + val origin = BrowserChrome.originOf(currentUrl()) + val certificate = BrowserWebTools.certificateInfo(wv) + if (origin == null) { + chrome?.showPageInfo(certificate) + return + } + querySitePermissions(origin) { decisions -> + updateUi { copy(sitePermissions = decisions.filterValues { it != Decision.ASK }) } + chrome?.showPageInfo(certificate) + } } /** Asks the main process to pin a launcher shortcut that reopens this page in Amethyst's browser. */ @@ -1446,12 +1521,6 @@ class NappletBrowserActivity : ComponentActivity() { queueToBroker(msg) } - private fun buildConsolePanel(): View = - NappletConsolePanel(this).also { - it.onClearCallback = { controlSheet?.updateConsoleCount(0) } - consolePanel = it - } - private fun buildLoadingView(): View = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -1521,10 +1590,11 @@ class NappletBrowserActivity : ComponentActivity() { private fun updateLoadProgress(progress: Int) { if (progress >= 100) { topProgressBar.visibility = View.GONE - controlSheet?.setLoading(false) + updateUi { copy(loadProgress = null, chrome = chrome.copy(isLoading = false)) } } else { topProgressBar.progress = progress topProgressBar.visibility = View.VISIBLE + updateUi { copy(loadProgress = progress / 100f) } } } @@ -1533,9 +1603,7 @@ class NappletBrowserActivity : ComponentActivity() { request: WebResourceRequest, message: String, ) { - val panel = consolePanel ?: return - panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) - controlSheet?.updateConsoleCount(panel.entryCount) + chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0)) } private fun resolveThemeColor(attr: Int): Int { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt index 01015fc67a..f814830628 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -145,10 +145,13 @@ object NappletBrowserContract { /** Clear the current site's cookies and storage in this account's profile, then reload. */ const val MSG_CLEAR_SITE_DATA = 24 - /** Ask for the page-info text (connection, Tor, certificate); answered with [MSG_PAGE_INFO]. */ + /** Ask for the page's certificate for page info; answered with [MSG_PAGE_INFO]. */ const val MSG_PAGE_INFO_REQUEST = 25 - /** Provider → client: [KEY_PAGE_INFO] for the page on screen. */ + /** + * Provider → client: the certificate of the page on screen — [KEY_CERT_ISSUED_TO], [KEY_CERT_ISSUED_BY] + * and [KEY_CERT_VALID_UNTIL], all absent for a page without one (plain HTTP). + */ const val MSG_PAGE_INFO = 26 /** @@ -188,7 +191,9 @@ object NappletBrowserContract { const val KEY_FIND_TOTAL = "findTotal" const val KEY_ENABLED = "enabled" const val KEY_TEXT_ZOOM = "textZoom" - const val KEY_PAGE_INFO = "pageInfo" + const val KEY_CERT_ISSUED_TO = "certIssuedTo" + const val KEY_CERT_ISSUED_BY = "certIssuedBy" + const val KEY_CERT_VALID_UNTIL = "certValidUntil" const val KEY_DIALOG_ID = "dialogId" const val KEY_DIALOG_TYPE = "dialogType" const val KEY_DIALOG_MESSAGE = "dialogMessage" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index aa5822da95..e58e8a861e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -262,8 +262,13 @@ class NappletBrowserService : Service() { NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> { val tab = tabFor(msg) ?: return true val wv = tab.webView ?: return true + val certificate = BrowserWebTools.certificateInfo(wv) sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) { - putString(NappletBrowserContract.KEY_PAGE_INFO, BrowserWebTools.pageInfo(this@NappletBrowserService, wv, if (tab.proxyPort > 0) tab.useTor else null)) + certificate?.let { + putString(NappletBrowserContract.KEY_CERT_ISSUED_TO, it.issuedTo) + putString(NappletBrowserContract.KEY_CERT_ISSUED_BY, it.issuedBy) + putString(NappletBrowserContract.KEY_CERT_VALID_UNTIL, it.validUntil) + } } } NappletBrowserContract.MSG_JS_DIALOG_RESULT -> { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletConsolePanel.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletConsolePanel.kt deleted file mode 100644 index 8c461caf09..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletConsolePanel.kt +++ /dev/null @@ -1,302 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.util.TypedValue -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.webkit.ConsoleMessage -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * The full-screen browser's **bottom pull-up sheet** for JavaScript console output. The whole sheet is - * hidden until the user flips the Console **toggle** in [NappletControlSheet] ([setShowing]); turned on, - * it reveals a small grabber at the bottom edge (symmetric to that sheet's top grabber) already pulled - * up. Pull it down/up (or tap) to collapse/expand the scrollable log of - * [console.log / warn / error / debug] messages captured from the page via - * `WebChromeClient.onConsoleMessage`. Capped at [MAX_ENTRIES] entries (oldest dropped on overflow). - * Built in plain Views like [NappletControlSheet] — no Compose/Material. - * - * Its grabber + panel are elevated above [NappletControlSheet]'s panel so that, when both are open at - * once (e.g. in landscape), this bottom sheet draws on top of the top pull-down sheet — mirroring the - * Compose layer, where `BottomConsoleSheet` is composed after `TopControlSheet`. - */ -@SuppressLint("UseSwitchCompatOrMaterialCode") -class NappletConsolePanel( - context: Context, -) : LinearLayout(context) { - private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) - private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary) - private val surface = resolveThemeColor(android.R.attr.colorBackground) - - private var expanded = false - private var showing = false - private val panel: LinearLayout - private lateinit var logContainer: LinearLayout - private lateinit var scrollView: ScrollView - - init { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - // Hidden until the Console toggle turns it on; matches the Compose `BottomConsoleSheet`, which is - // only composed while the toggle is on. - visibility = View.GONE - - panel = buildPanel().also { addView(it) } - addView(buildGrabber()) - } - - private fun buildPanel(): LinearLayout = - LinearLayout(context).apply { - orientation = VERTICAL - visibility = View.GONE - // Above NappletControlSheet's panel (6dp) so an open console draws over an open top sheet. - elevation = dp(8).toFloat() - background = - GradientDrawable().apply { - cornerRadii = floatArrayOf(dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), 0f, 0f, 0f, 0f) - setColor(surface) - } - setPadding(dp(8), dp(10), dp(8), dp(6)) - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(220)) - - val headerRow = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), 0, dp(4), dp(4)) - addView( - TextView(context).apply { - text = context.getString(CommonsR.string.browser_console_title_short) - setTextColor(dimmed) - textSize = 12f - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - }, - ) - addView( - TextView(context).apply { - text = context.getString(CommonsR.string.browser_console_clear) - setTextColor(dimmed) - textSize = 12f - setPadding(dp(12), dp(6), dp(12), dp(6)) - isClickable = true - setOnClickListener { clearLogs() } - }, - ) - } - - val container = - LinearLayout(context).apply { - orientation = VERTICAL - } - logContainer = container - - val sv = - ScrollView(context).apply { - addView(container, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, 0, 1f) - } - scrollView = sv - - addView(headerRow) - addView(sv) - } - - /** Number of log entries currently stored (used to update the control sheet count label). */ - var entryCount: Int = 0 - private set - - /** - * Shows or hides the entire sheet (grabber + log), driven by the control sheet's Console **toggle**: - * off hides everything, on reveals the sheet already pulled up — mirroring the Compose - * `BottomConsoleSheet`, which is only composed while the toggle is on and opens expanded. - */ - fun setShowing(show: Boolean) { - if (show == showing) return - showing = show - if (show) { - visibility = View.VISIBLE - expand() - } else { - collapse() - visibility = View.GONE - } - } - - fun appendLog( - level: ConsoleMessage.MessageLevel, - message: String, - source: String, - lineNumber: Int, - ) { - if (entryCount >= MAX_ENTRIES && logContainer.childCount > 0) { - logContainer.removeViewAt(0) - } else { - entryCount++ - } - - val levelChar = - when (level) { - ConsoleMessage.MessageLevel.ERROR -> "E" - ConsoleMessage.MessageLevel.WARNING -> "W" - ConsoleMessage.MessageLevel.TIP -> "T" - ConsoleMessage.MessageLevel.DEBUG -> "D" - else -> "I" - } - val levelColor = - when (level) { - ConsoleMessage.MessageLevel.ERROR -> Color.RED - ConsoleMessage.MessageLevel.WARNING -> Color.rgb(255, 152, 0) - ConsoleMessage.MessageLevel.TIP -> Color.CYAN - ConsoleMessage.MessageLevel.DEBUG -> dimmed - else -> onSurface - } - - val srcShort = - source - .substringAfterLast("/") - .substringAfterLast("\\") - .let { if (it.isBlank()) source.takeLast(20) else it } - val annotation = if (srcShort.isNotBlank()) " ($srcShort:$lineNumber)" else "" - - val entry = - TextView(context).apply { - text = "$levelChar $message$annotation" - setTextColor(levelColor) - textSize = 11f - typeface = Typeface.MONOSPACE - setPadding(dp(4), dp(2), dp(4), dp(2)) - } - logContainer.addView(entry) - scrollView.post { scrollView.fullScroll(View.FOCUS_DOWN) } - } - - private fun clearLogs() { - logContainer.removeAllViews() - entryCount = 0 - // Return a callback so the activity can update the control sheet count after clearing. - onClearCallback?.invoke() - } - - var onClearCallback: (() -> Unit)? = null - - /** The grabber: a small rounded bar centered at the bottom edge. Tap toggles, vertical drag opens/closes. */ - @SuppressLint("ClickableViewAccessibility") - private fun buildGrabber(): View { - val bar = - View(context).apply { - background = - GradientDrawable().apply { - cornerRadius = dp(3).toFloat() - setColor(dimmed and 0x99FFFFFF.toInt()) - } - layoutParams = LayoutParams(dp(36), dp(5)) - } - return LinearLayout(context).apply { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - layoutParams = - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - gravity = Gravity.CENTER_HORIZONTAL - } - setPadding(dp(16), dp(7), dp(16), dp(7)) - // Above NappletControlSheet's panel (6dp) so the grabber stays on top of an open top sheet. - elevation = dp(8).toFloat() - background = - GradientDrawable().apply { - cornerRadii = floatArrayOf(dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), 0f, 0f, 0f, 0f) - setColor(withAlpha(surface, 0.6f)) - } - isClickable = true - contentDescription = context.getString(CommonsR.string.browser_console_title_short) - addView(bar) - - var downY = 0f - var dragged = false - setOnTouchListener { _, ev -> - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN -> { - downY = ev.rawY - dragged = false - true - } - MotionEvent.ACTION_MOVE -> { - val dy = ev.rawY - downY - if (dy < -dp(8)) { - expand() - dragged = true - } else if (dy > dp(8)) { - collapse() - dragged = true - } - true - } - MotionEvent.ACTION_UP -> { - if (!dragged) { - if (expanded) collapse() else expand() - } - true - } - else -> false - } - } - } - } - - private fun expand() { - if (expanded) return - expanded = true - panel.visibility = View.VISIBLE - } - - private fun collapse() { - if (!expanded) return - expanded = false - panel.visibility = View.GONE - } - - private fun withAlpha( - color: Int, - alpha: Float, - ): Int = (color and 0x00FFFFFF) or ((alpha * 255).toInt() shl 24) - - private fun resolveThemeColor(attr: Int): Int { - val tv = TypedValue() - context.theme.resolveAttribute(attr, tv, true) - return if (tv.resourceId != 0) ContextCompat.getColor(context, tv.resourceId) else tv.data.takeIf { it != 0 } ?: Color.GRAY - } - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - private companion object { - private const val MAX_ENTRIES = 200 - } -} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt deleted file mode 100644 index d1546410bf..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ /dev/null @@ -1,647 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.napplethost - -import android.annotation.SuppressLint -import android.content.Context -import android.graphics.Color -import android.graphics.Typeface -import android.graphics.drawable.GradientDrawable -import android.text.InputType -import android.text.TextUtils -import android.util.TypedValue -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager -import android.widget.EditText -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.ScrollView -import android.widget.Switch -import android.widget.TextView -import androidx.core.content.ContextCompat -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action -import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.R as CommonsR - -/** - * The full-screen surfaces' **top pull-down pill** — the plain-View twin of the embedded tabs' Compose - * `TopControlSheet`. Collapsed it's a small grabber centered at the top edge, out of the corner where a site - * puts its own avatar/menu. Pulled down (or tapped) it shows, like a Chrome PWA's app menu: - * - * - a header with the page title, its origin + connection badge (tap: page info; long-press: copy link), - * and a close button; - * - the icon row (back · forward · reload/stop · star · share); - * - the menu rows, then Privacy and Developer groups. - * - * *Which* actions appear, and in what order, comes from [BrowserChrome] — the same source the Compose sheet - * uses — and each action's icon/label from [BrowserChromeLabels]. This class only draws them. The page - * can't draw over it. Built in code (no XML, no Compose/Material) because `:nappletHost` stays light. - */ -@SuppressLint("UseSwitchCompatOrMaterialCode", "ViewConstructor") -class NappletControlSheet( - context: Context, - initialState: BrowserChrome.State, - title: String, - private val listener: Listener, - isFavoriteInitially: Boolean = false, - /** Shows the header's close button (finishing the window). */ - private val showClose: Boolean = true, -) : LinearLayout(context) { - /** What the sheet asks its host to do. Every row and icon ends up in [onAction]. */ - interface Listener { - fun onAction(action: Action) - - /** The user typed an address into "Edit address" and pressed Go. */ - fun onNavigate(text: String) {} - - /** The text-size stepper moved to [percent]. */ - fun onTextZoom(percent: Int) {} - - /** The origin chip was tapped: show page info (or the access summary for sandboxed apps). */ - fun onOriginTap() {} - - fun onClose() {} - } - - private val onSurface = resolveThemeColor(android.R.attr.textColorPrimary) - private val dimmed = resolveThemeColor(android.R.attr.textColorSecondary) - private val surface = resolveThemeColor(android.R.attr.colorBackground) - private val accent = resolveThemeColor(android.R.attr.colorPrimary) - private val glyphs: Typeface = BrowserGlyphs.typeface(context) - - var state: BrowserChrome.State = initialState - private set - private var title: String = title - private var isFavorite = isFavoriteInitially - private var desktopSite = false - private var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM - private var consoleShowing = false - private var consoleCount = 0 - private var editingAddress = false - - private var expanded = false - private val panel: LinearLayout - private val grabber: View - - init { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - panel = - LinearLayout(context).apply { - orientation = VERTICAL - visibility = View.GONE - elevation = dp(6).toFloat() - background = - GradientDrawable().apply { - cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat(), dp(16).toFloat()) - setColor(surface) - } - setPadding(dp(8), dp(6), dp(8), dp(10)) - } - addView(panel) - grabber = buildGrabber() - addView(grabber) - } - - // ---- state updates from the host ---- - - /** - * The page navigated. Moving to another site swaps the title for that site's host until its page title - * arrives ([updateTitle]), and marks the pin state unknown until the host answers ([setFavorite]). - */ - fun updateUrl(url: String) { - val previous = state.url - if (url == previous) return - state = state.copy(url = url) - if (BrowserChrome.displayHost(url) != BrowserChrome.displayHost(previous)) title = BrowserChrome.displayHost(url) - // Unknown until the host answers; "Add" meanwhile. The toggle sends an explicit target state, so a - // tap in that window can only add (idempotent), never silently remove an existing pin. - isFavorite = false - refresh() - } - - /** Shows the page's ``, falling back to the host (WebView reports the URL for untitled pages). */ - fun updateTitle(pageTitle: String?) { - val real = pageTitle?.trim()?.takeIf { it.isNotEmpty() && it != state.url } - title = real ?: BrowserChrome.displayHost(state.url) - refresh() - } - - /** Applies the registry's answer for [url]; ignored once the user has moved on to another page. */ - fun setFavorite( - url: String, - favorite: Boolean, - ) { - if (url != state.url) return - isFavorite = favorite - refresh() - } - - fun setNavigation( - canGoBack: Boolean, - canGoForward: Boolean, - ) = update(state.copy(canGoBack = canGoBack, canGoForward = canGoForward)) - - fun setLoading(loading: Boolean) = update(state.copy(isLoading = loading)) - - fun setTor(on: Boolean) = update(state.copy(torOn = on)) - - fun setDesktopSite(on: Boolean) { - desktopSite = on - refresh() - } - - fun setTextZoom(percent: Int) { - textZoom = percent - refresh() - } - - fun setConsoleShowing(showing: Boolean) { - consoleShowing = showing - refresh() - } - - fun updateConsoleCount(count: Int) { - consoleCount = count - refresh() - } - - private fun update(next: BrowserChrome.State) { - if (next == state) return - state = next - refresh() - } - - /** Rebuilds the open panel. Collapsed, nothing is drawn, so it waits for the next [expand]. */ - private fun refresh() { - grabber.contentDescription = title - if (expanded) render() - } - - // ---- drawing ---- - - private fun render() { - panel.removeAllViews() - panel.addView(header()) - panel.addView(iconRow()) - panel.addView(divider()) - val sections = - LinearLayout(context).apply { - orientation = VERTICAL - BrowserChrome.sections(state).forEachIndexed { index, section -> - if (index > 0) addView(divider()) - sectionTitle(section.kind)?.let { addView(sectionLabel(it)) } - section.actions.forEach { addView(row(it)) } - } - } - panel.addView( - MaxHeightScrollView(context, (resources.displayMetrics.heightPixels * 0.6f).toInt()).apply { - isVerticalScrollBarEnabled = false - addView(sections) - }, - ) - } - - private fun sectionTitle(kind: SectionKind): String? = - when (kind) { - SectionKind.PAGE -> null - SectionKind.PRIVACY -> context.getString(CommonsR.string.browser_section_privacy) - SectionKind.DEVELOPER -> context.getString(CommonsR.string.browser_section_developer) - } - - private fun header(): View = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(6), dp(4), dp(6)) - val security = BrowserChrome.security(state) - addView(iconView(BrowserChromeLabels.securitySymbol(security), BrowserChromeLabels.securityDrawable(security), dimmed, 20)) - if (editingAddress) { - addView(addressField()) - } else { - addView( - LinearLayout(context).apply { - orientation = VERTICAL - setPadding(dp(12), 0, dp(8), 0) - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - isClickable = true - setOnClickListener { - collapse() - listener.onOriginTap() - } - if (!state.isSandbox) { - setOnLongClickListener { - listener.onAction(Action.COPY_LINK) - true - } - } - addView( - TextView(context).apply { - text = title - setTextColor(onSurface) - textSize = 16f - typeface = Typeface.DEFAULT_BOLD - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - }, - ) - addView( - TextView(context).apply { - text = - if (state.isSandbox) { - context.getString(BrowserChromeLabels.securityLabel(security)) - } else { - BrowserChrome.displayHost(state.url) + " · " + context.getString(BrowserChromeLabels.securityLabel(security)) - } - setTextColor(dimmed) - textSize = 13f - maxLines = 1 - ellipsize = TextUtils.TruncateAt.MIDDLE - }, - ) - }, - ) - } - if (showClose) { - addView( - glyphButton(BrowserChromeLabels.symbolFor(Action.STOP)!!, context.getString(CommonsR.string.browser_action_close), onSurface) { - collapse() - listener.onClose() - }, - ) - } - } - - /** The rarely used editable address, swapped in for the origin chip by the "Edit address" row. */ - private fun addressField(): View { - val field = - EditText(context).apply { - setText(state.url) - setTextColor(onSurface) - setHintTextColor(dimmed) - hint = context.getString(CommonsR.string.browser_address_hint) - contentDescription = context.getString(CommonsR.string.browser_address_hint) - textSize = 15f - isSingleLine = true - setSelectAllOnFocus(true) - background = null - inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI - imeOptions = EditorInfo.IME_ACTION_GO - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(8) } - setOnEditorActionListener { v, actionId, _ -> - if (actionId != EditorInfo.IME_ACTION_GO) return@setOnEditorActionListener false - val text = - v.text - ?.toString() - ?.trim() - .orEmpty() - if (text.isNotEmpty()) { - hideKeyboard(v) - editingAddress = false - collapse() - listener.onNavigate(text) - } - true - } - } - field.post { - field.requestFocus() - context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0) - } - return field - } - - private fun iconRow(): View = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(0, dp(2), 0, dp(4)) - BrowserChrome.iconRow(state).forEach { action -> - val enabled = BrowserChrome.isEnabled(state, action) - val tint = if (action == Action.FAVORITE && isFavorite) accent else onSurface - val button = - glyphButton( - BrowserChromeLabels.symbolFor(action)!!, - context.getString(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite)), - tint, - ) { - collapse() - onRowAction(action) - } - if (action == Action.FAVORITE && isFavorite) button.typeface = BrowserGlyphs.filledTypeface(context) - button.isEnabled = enabled - button.alpha = if (enabled) 1f else 0.35f - button.layoutParams = LayoutParams(0, dp(48), 1f) - addView(button) - } - } - - private fun row(action: Action): View { - val label = - when (action) { - Action.BACK_TO_APP -> context.getString(CommonsR.string.browser_action_back_to_app, BrowserChrome.displayHost(state.startUrl)) - else -> context.getString(BrowserChromeLabels.labelFor(action, isFavorite = isFavorite, torOn = state.torOn == true)) - } - val consoleLabel = if (action == Action.CONSOLE && consoleCount > 0) context.getString(CommonsR.string.browser_console_title, consoleCount) else label - return LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(10), dp(8), dp(10)) - addView(iconView(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), dimmed, 22)) - addView( - TextView(context).apply { - text = consoleLabel - setTextColor(onSurface) - textSize = 15f - setPadding(dp(14), 0, 0, 0) - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - }, - ) - if (action == Action.TEXT_SIZE) { - addView(textSizeStepper()) - } else { - isClickable = true - setOnClickListener { - if (!BrowserChromeLabels.keepsSheetOpen(action)) collapse() - onRowAction(action) - } - if (BrowserChromeLabels.isToggle(action)) { - // Display-only: the whole row is the touch target, as in the Compose twin. - addView( - Switch(context).apply { - isChecked = - when (action) { - Action.TOR -> state.torOn == true - Action.DESKTOP_SITE -> desktopSite - else -> consoleShowing - } - isClickable = false - isFocusable = false - }, - ) - } - } - } - } - - private fun textSizeStepper(): View = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - addView( - glyphButton(MaterialSymbols.Remove, context.getString(CommonsR.string.browser_action_text_smaller), onSurface) { - textZoom = BrowserChrome.stepTextZoom(textZoom, larger = false) - listener.onTextZoom(textZoom) - refresh() - }, - ) - addView( - TextView(context).apply { - text = context.getString(CommonsR.string.browser_action_text_size_value, textZoom) - setTextColor(onSurface) - textSize = 14f - gravity = Gravity.CENTER - minWidth = dp(48) - }, - ) - addView( - glyphButton(MaterialSymbols.Add, context.getString(CommonsR.string.browser_action_text_larger), onSurface) { - textZoom = BrowserChrome.stepTextZoom(textZoom, larger = true) - listener.onTextZoom(textZoom) - refresh() - }, - ) - } - - private fun onRowAction(action: Action) { - when (action) { - Action.EDIT_ADDRESS -> { - editingAddress = true - refresh() - } - Action.FAVORITE -> { - isFavorite = !isFavorite - listener.onAction(action) - } - Action.DESKTOP_SITE -> { - desktopSite = !desktopSite - listener.onAction(action) - } - Action.CONSOLE -> { - consoleShowing = !consoleShowing - listener.onAction(action) - } - else -> listener.onAction(action) - } - } - - /** The favorite state the user is asking for (read by the host right after a FAVORITE action). */ - fun wantsFavorite(): Boolean = isFavorite - - private fun sectionLabel(text: String): View = - TextView(context).apply { - this.text = text - setTextColor(dimmed) - textSize = 12f - isAllCaps = true - letterSpacing = 0.06f - setPadding(dp(8), dp(10), dp(8), dp(2)) - } - - private fun iconView( - symbol: MaterialSymbol?, - drawable: Int?, - tint: Int, - sizeDp: Int, - ): View = - if (drawable != null) { - ImageView(context).apply { - setImageResource(drawable) - setColorFilter(tint) - layoutParams = LayoutParams(dp(sizeDp), dp(sizeDp)) - } - } else { - glyphText(symbol?.glyph.orEmpty(), tint, sizeDp).apply { layoutParams = LayoutParams(dp(sizeDp + 4), LayoutParams.WRAP_CONTENT) } - } - - private fun glyphText( - glyph: String, - tint: Int, - sizeDp: Int, - ): TextView = - TextView(context).apply { - text = glyph - typeface = glyphs - setTextColor(tint) - setTextSize(TypedValue.COMPLEX_UNIT_DIP, sizeDp.toFloat()) - gravity = Gravity.CENTER - includeFontPadding = false - } - - private fun glyphButton( - symbol: MaterialSymbol, - description: String, - tint: Int, - onClick: () -> Unit, - ): TextView = - glyphText(symbol.glyph, tint, 22).apply { - contentDescription = description - tooltipText = description - minWidth = dp(44) - minHeight = dp(44) - isClickable = true - isFocusable = true - background = selectableBackground() - // Mirrored glyphs (back/forward) flip for right-to-left layouts, as Compose's autoMirror does. - if (symbol.autoMirror && resources.configuration.layoutDirection == LAYOUT_DIRECTION_RTL) scaleX = -1f - setOnClickListener { onClick() } - } - - private fun selectableBackground() = - TypedValue().let { tv -> - context.theme.resolveAttribute(android.R.attr.selectableItemBackgroundBorderless, tv, true) - ContextCompat.getDrawable(context, tv.resourceId) - } - - private fun divider(): View = - View(context).apply { - setBackgroundColor(dimmed and 0x33FFFFFF) - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(1)).apply { setMargins(0, dp(2), 0, dp(2)) } - } - - private fun hideKeyboard(view: View) { - context.getSystemService(InputMethodManager::class.java)?.hideSoftInputFromWindow(view.windowToken, 0) - } - - /** The grabber: a small rounded bar centered at the top edge; tap toggles, vertical drag opens/closes. */ - @SuppressLint("ClickableViewAccessibility") - private fun buildGrabber(): View { - val bar = - View(context).apply { - background = - GradientDrawable().apply { - cornerRadius = dp(3).toFloat() - setColor(dimmed and 0x99FFFFFF.toInt()) - } - layoutParams = LayoutParams(dp(36), dp(5)) - } - return LinearLayout(context).apply { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - layoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { gravity = Gravity.CENTER_HORIZONTAL } - setPadding(dp(16), dp(7), dp(16), dp(7)) - background = - GradientDrawable().apply { - cornerRadii = floatArrayOf(0f, 0f, 0f, 0f, dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat(), dp(12).toFloat()) - setColor(withAlpha(surface, 0.6f)) - } - isClickable = true - contentDescription = title - addView(bar) - - var downY = 0f - var dragged = false - setOnTouchListener { _, ev -> - when (ev.actionMasked) { - MotionEvent.ACTION_DOWN -> { - downY = ev.rawY - dragged = false - true - } - MotionEvent.ACTION_MOVE -> { - val dy = ev.rawY - downY - if (dy > dp(8)) { - expand() - dragged = true - } else if (dy < -dp(8)) { - collapse() - dragged = true - } - true - } - MotionEvent.ACTION_UP -> { - if (!dragged) { - if (expanded) { - collapse() - } else { - expand() - } - } - true - } - else -> false - } - } - } - } - - val isExpanded: Boolean get() = expanded - - fun expand() { - if (expanded) return - expanded = true - render() - panel.visibility = View.VISIBLE - } - - fun collapse() { - if (!expanded) return - expanded = false - if (editingAddress) { - editingAddress = false - hideKeyboard(panel) - } - panel.visibility = View.GONE - panel.removeAllViews() - } - - private fun withAlpha( - color: Int, - alpha: Float, - ): Int = (color and 0x00FFFFFF) or ((alpha * 255).toInt() shl 24) - - private fun resolveThemeColor(attr: Int): Int { - val tv = TypedValue() - context.theme.resolveAttribute(attr, tv, true) - return if (tv.resourceId != 0) ContextCompat.getColor(context, tv.resourceId) else tv.data.takeIf { it != 0 } ?: Color.GRAY - } - - private fun dp(value: Int): Int = (value * resources.displayMetrics.density).toInt() - - /** A [ScrollView] that stops growing at [maxHeightPx], so a long menu scrolls instead of covering the page. */ - private class MaxHeightScrollView( - context: Context, - private val maxHeightPx: Int, - ) : ScrollView(context) { - override fun onMeasure( - widthMeasureSpec: Int, - heightMeasureSpec: Int, - ) { - super.onMeasure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(maxHeightPx, MeasureSpec.AT_MOST)) - } - } -} 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 ca4d8ddc56..b16ab84463 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -26,6 +26,7 @@ import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.res.ColorStateList +import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.os.Handler @@ -64,6 +65,10 @@ import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent +import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.util.booleanOrNull @@ -194,11 +199,8 @@ class NappletHostActivity : ComponentActivity() { // WebChromeClient's onProgressChanged; hidden at 100%. private val topProgressBar by lazy { buildTopProgressBar() } - // Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors. - private var consolePanel: NappletConsolePanel? = null - private var controlSheet: NappletControlSheet? = null - private var findBar: BrowserFindBar? = null - private var consoleShowing = false + // The trusted pull-down pill, find and the developer console — the shared Compose chrome. + private var chrome: BrowserChromeHost? = null // Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again. private var webViewGone = false @@ -211,8 +213,8 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (findBar?.isShowing == true) { - findBar?.hide() + if (chrome?.handleBack() == true) { + Unit } else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { webView.goBack() } else { @@ -225,7 +227,7 @@ class NappletHostActivity : ComponentActivity() { /** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */ private fun syncBackState() { val canGoBack = this::webView.isInitialized && !webViewGone && webView.canGoBack() - backCallback.isEnabled = canGoBack || findBar?.isShowing == true + backCallback.isEnabled = canGoBack || chrome?.wantsBack == true } // True between onResume and onPause. Sent to the broker (foreground hold) on connect too, in case @@ -294,6 +296,7 @@ class NappletHostActivity : ComponentActivity() { // profile has otherwise been used), so the storage partition must be chosen before anything else. NappletWebViewProfile.apply(this, webView, webViewProfile) hardenWebView(webView) + webView.setFindListener { active, total, _ -> chrome?.setFindResult(active, total) } // Theme the WebView's pre-paint background to the app's so it doesn't flash white when the shell // mounts. This activity has a themed context, so it resolves the color locally (no IPC needed). webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) @@ -321,33 +324,11 @@ class NappletHostActivity : ComponentActivity() { val root = FrameLayout(this).apply { addView(contentFrame, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - addView( - buildControlSheet(), - FrameLayout - .LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - Gravity.TOP, - ), - ) - addView( - buildConsolePanel(), - FrameLayout - .LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - Gravity.BOTTOM, - ), - ) - addView( - BrowserFindBar(this@NappletHostActivity, { if (this@NappletHostActivity::webView.isInitialized && !webViewGone) webView else null }) { syncBackState() } - .also { findBar = it }, - FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT, Gravity.BOTTOM), - ) - // Added last so the thin loading bar paints above the content (and over the grabber's top - // edge); it's GONE except while loading, so it never obscures the trusted chrome. - addView(topProgressBar) } + chrome = buildChrome().also { it.attach(root) } + // Added last so the thin loading bar paints above the content (and over the grabber's top edge); it's + // GONE except while loading, so it never obscures the trusted chrome. + root.addView(topProgressBar) setContentView(root) // Activities are edge-to-edge by default on recent Android; pad by the system bar, display-cutout // and IME insets so neither the chrome nor the applet draws under the system bars or the soft @@ -669,7 +650,7 @@ class NappletHostActivity : ComponentActivity() { ): Boolean { Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" } webViewGone = true - findBar?.hide() + chrome?.closeFind() (view.parent as? ViewGroup)?.removeView(view) view.destroy() loadingView?.let { contentFrame.removeView(it) } @@ -721,9 +702,10 @@ class NappletHostActivity : ComponentActivity() { } override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { - val panel = consolePanel ?: return false - panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) - controlSheet?.updateConsoleCount(panel.entryCount) + val host = chrome ?: return false + host.appendConsole( + ConsoleLine(BrowserChromeHost.levelOf(consoleMessage.messageLevel()), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()), + ) return true } } @@ -751,9 +733,11 @@ class NappletHostActivity : ComponentActivity() { private fun updateLoadProgress(progress: Int) { if (progress >= 100) { topProgressBar.visibility = View.GONE + chrome?.let { it.ui = it.ui.copy(loadProgress = null, chrome = it.ui.chrome.copy(isLoading = false)) } } else { topProgressBar.progress = progress topProgressBar.visibility = View.VISIBLE + chrome?.let { it.ui = it.ui.copy(loadProgress = progress / 100f, chrome = it.ui.chrome.copy(isLoading = true)) } } } @@ -762,9 +746,7 @@ class NappletHostActivity : ComponentActivity() { request: WebResourceRequest, message: String, ) { - val panel = consolePanel ?: return - panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) - controlSheet?.updateConsoleCount(panel.entryCount) + chrome?.appendConsole(ConsoleLine(ConsoleLine.Level.ERROR, message, request.url?.toString().orEmpty(), 0)) } // ---- bridge: shell <-> native ---- @@ -937,63 +919,88 @@ class NappletHostActivity : ComponentActivity() { private fun barTitle(): String = title.ifBlank { getString(CommonsR.string.napplet_untitled) } /** - * The trusted top pull-down sheet: a small grabber at the top edge (out of the corner where the app - * shows its own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website - * mode only, taps through to the confirm dialog), reload, and the "what it can access" sheet. The - * applet can't draw over it. Mirrors the embedded tabs' Compose `TopControlSheet`. + * The trusted pull-down pill: a small grabber at the top edge (out of the corner where the app shows its + * own avatar) that expands to the sandbox **shield**, the nSite network/Tor row (website mode only, taps + * through to a relaunch), reload, find, text size and "what it can access". The applet can't draw over + * it. The same Compose components as the embedded tabs and the web browser. */ - private fun buildControlSheet(): View = - NappletControlSheet( - context = this, - initialState = - BrowserChrome.State( - surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, - presentation = BrowserChrome.Presentation.FULL_SCREEN, - url = "", - startUrl = "", - // Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row - // taps through to a full relaunch rather than toggling inline. - torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, - canFavorite = false, - hasAccessInfo = true, + private fun buildChrome(): BrowserChromeHost = + BrowserChromeHost( + activity = this, + dark = isDarkTheme(), + initial = + BrowserPillUi( + title = barTitle(), + chrome = + BrowserChrome.State( + surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = "", + startUrl = "", + // Website-mode nSites can re-route over Tor; switching rebuilds the session, so the + // row taps through to a full relaunch rather than toggling inline. + torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, + canFavorite = false, + hasAccessInfo = true, + ), ), - title = barTitle(), - listener = - object : NappletControlSheet.Listener { - override fun onAction(action: BrowserChrome.Action) { - val wv = if (this@NappletHostActivity::webView.isInitialized && !webViewGone) webView else null - when (action) { - BrowserChrome.Action.RELOAD -> wv?.reload() - BrowserChrome.Action.STOP -> wv?.stopLoading() - BrowserChrome.Action.FIND_IN_PAGE -> { - setConsoleShowing(false) - findBar?.show() - syncBackState() - } + listener = chromeListener, + ) + + private fun isDarkTheme(): Boolean = + when (themeType) { + "DARK" -> true + "LIGHT" -> false + else -> (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES + } + + private fun liveWebView(): WebView? = if (this::webView.isInitialized && !webViewGone) webView else null + + private val chromeListener = + object : BrowserChromeHost.Listener { + override fun onPillEvent(event: BrowserPillEvent) { + when (event) { + is BrowserPillEvent.Action -> + when (event.action) { + BrowserChrome.Action.RELOAD -> liveWebView()?.reload() + BrowserChrome.Action.STOP -> liveWebView()?.stopLoading() BrowserChrome.Action.TOR -> setNetworkMode(!useTor) BrowserChrome.Action.ACCESS_INFO -> showAccessDialog() BrowserChrome.Action.SITE_SETTINGS -> openPermissions() - BrowserChrome.Action.CONSOLE -> setConsoleShowing(!consoleShowing) else -> Unit } + is BrowserPillEvent.TextZoom -> { + liveWebView()?.let { BrowserWebTools.setTextZoom(it, event.percent) } + chrome?.let { it.ui = it.ui.copy(textZoom = event.percent) } } + BrowserPillEvent.PageInfo -> showAccessDialog() + BrowserPillEvent.Close -> finish() + else -> Unit + } + } - override fun onTextZoom(percent: Int) { - if (this@NappletHostActivity::webView.isInitialized && !webViewGone) BrowserWebTools.setTextZoom(webView, percent) - } + override fun onFind(query: String) { + val wv = liveWebView() ?: return + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } - override fun onOriginTap() = showAccessDialog() + override fun onFindNext(forward: Boolean) { + liveWebView()?.findNext(forward) + } - override fun onClose() = finish() - }, - ).also { controlSheet = it } + override fun onFindClosed() { + liveWebView()?.clearMatches() + } - private fun setConsoleShowing(showing: Boolean) { - consoleShowing = showing - if (showing) findBar?.hide() - consolePanel?.setShowing(showing) - controlSheet?.setConsoleShowing(showing) - } + override fun onPermissionChange( + permission: BrowserSitePermission, + decision: BrowserSitePermission.Decision, + ) = Unit + + override fun onClearSiteData() = Unit + + override fun onPanelsChanged() = syncBackState() + } /** * Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own @@ -1008,12 +1015,6 @@ class NappletHostActivity : ComponentActivity() { if (brokerMessenger != null) sendToBroker(msg) } - private fun buildConsolePanel(): View = - NappletConsolePanel(this).also { - it.onClearCallback = { controlSheet?.updateConsoleCount(0) } - consolePanel = it - } - /** * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by * [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone