From 76b2269456a7e1df250ef39be7c3594371243649 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:22:17 +0000 Subject: [PATCH 01/14] docs(plans): browser surfaces PWA-parity review Gap analysis of the embedded web tab and full-screen browser (top pull-down and bottom console sheets, WebView behaviours) against an installed Chrome PWA, with bugs found, a unified chrome proposal, and a phased plan. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-pwa-parity.md | 269 ++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 amethyst/plans/2026-09-26-browser-pwa-parity.md diff --git a/amethyst/plans/2026-09-26-browser-pwa-parity.md b/amethyst/plans/2026-09-26-browser-pwa-parity.md new file mode 100644 index 0000000000..e9c658cf62 --- /dev/null +++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md @@ -0,0 +1,269 @@ +# Browser surfaces → PWA parity review + +Status: **review / proposal** (no code changed yet). Scope: every surface that renders a +plain web client — the **embedded** bottom-bar tab and the **external** full-screen browser — +plus their shared top pull-down "pill" and bottom console "pill". The nsite/napplet hosts +reuse the same chrome and are covered where the change is shared. + +Benchmark: an installed PWA in Chrome for Android (WebAPK, `display: standalone` / +`minimal-ui`), including the Custom-Tab-style bar Chrome shows when a PWA navigates out of +its scope. + +## 1. Surfaces today + +| Surface | Code | Process / rendering | Chrome | +|---|---|---|---| +| **Embedded web tab** (bottom-bar favorite) | `WebAppScreen` → `EmbeddedWebAppController` → `NappletBrowserService` | `:napplet` WebView streamed via SurfaceControlViewHost (API 30+) | Compose `TopControlSheet` + `BottomConsoleSheet`, drawn by `EmbeddedTabLayer` | +| **External browser** (Browser tab launcher, "Open full") | `BrowserScreen` → `FavoriteAppLauncher.launchUrl` → `NappletBrowserActivity` | `:napplet` WebView hosted directly; own task (`documentLaunchMode=intoExisting`) | native-View `NappletControlSheet` + `NappletConsolePanel` | +| Embedded / full nsite + napplet | `NostrAppScreen` / `NappletHostService` / `NappletHostActivity` | same split, verified-blob shell | same two chrome implementations, `isSandbox = true` | + +So there are **two implementations of the same chrome** (Compose + plain Views), and they have +drifted. + +### Top pill — current contents + +| Row | Embedded (Compose) | External (Views) | +|---|---|---| +| Header | icon + host title | emoji 🌐/🛡 + **launch-time** title (never updates) | +| Address | — | **editable** URL field + 🔒/🧅/🌐 glyph | +| Tor | switch, `Lock` icon, `favorite_app_network_*` strings | switch, `ic_tor` icon, `napplet_net_*` strings | +| Reload | ✓ | ✓ (glyph `↻`) | +| Access info | sandbox only | sandbox only (glyph `ⓘ`) | +| Manage permissions | ✓ (navigates in-app) | ✓ (glyph `⚙`, IPC → broker) | +| Open full screen | ✓ | — (no way back to the tab) | +| Favorite | ✓, reflects registry live | ✓, but **resets to "Add" on every navigation** | +| Console | switch | switch (glyph `>`) | + +### Bottom pill + +Console only: hidden until the top-pill Console switch turns it on, then a grabber + log panel +(max 40 % height in Compose, a fixed 220 dp in Views). Neither surface has a bottom +navigation/actions pill. + +## 2. What a Chrome PWA gives the user + +**Window / OS integration** +- Its own launcher icon (Add to Home screen / Install), its own task in Recents with the + **app's name, icon and `theme_color`**, and a splash screen built from the manifest. +- Status bar (and nav bar) tinted with `theme_color` / ``, updated + live when the page changes it. +- `display_override` / `display` modes: `standalone` (no UI), `minimal-ui` (back + reload), + `fullscreen`. +- Launch handling: `start_url`, `scope`, `launch_handler`; deep links into the scope open the + PWA; app shortcuts (manifest `shortcuts`, shown on launcher long-press); `share_target` + (appears in the Android share sheet). + +**Navigation** +- System back = history back; at the root, back leaves the app. +- **Out-of-scope** navigation (e.g. an OAuth provider) opens a Custom-Tab-like bar showing the + **origin + security state + ✕ close**, and returns to the app when closed. +- `target=_blank` / `window.open()` open a new Chrome tab (or a popup window for OAuth) and + `window.opener`/`postMessage` works. +- Non-web schemes (`mailto:`, `tel:`, `intent:`, `geo:`, `nostr:`…) hand off to apps. + +**App menu** (minimal-ui ⋮, or the out-of-scope bar ⋮) — top icon row +`Forward · Reload/Stop`, then: Share…, Copy link, Open in Chrome, Find in page, Desktop site, +Zoom/text size, Page info (connection, certificate, cookies, site settings / permissions), +Clear site data, App info. + +**Web platform APIs that "just work"** +- JS dialogs (`alert`/`confirm`/`prompt`, `beforeunload`), labelled with the origin. +- Permission prompts: camera/mic (`getUserMedia`), geolocation, notifications + Push, + clipboard read, MIDI, protected media — with a per-origin site-settings page. +- Downloads (``, `Content-Disposition`, blob: URLs) to the Downloads folder. +- HTML fullscreen (`requestFullscreen`, the video player's fullscreen button), screen + orientation lock, Wake Lock. +- Web Share (`navigator.share`) and Share Target, Badging API, Contact Picker, File System + Access (open/save pickers), ``. +- Long-press context menu on links/images (open in new tab, copy link, share, download + image), text selection toolbar with Share/Translate/Search. +- Pull-to-refresh (unless the page sets `overscroll-behavior`). +- Service worker offline support, a proper offline/error page with Retry. + +## 3. Gap analysis + +Legend: ✅ parity · ⚠️ partial · ❌ missing · 🔒 intentionally blocked (sandbox/Tor/keyless — keep it or gate it behind consent). + +| Capability | Embedded | External | Notes | +|---|---|---|---| +| Own task/Recents entry | n/a (it's a tab) | ⚠️ | Own task exists, but Recents shows the Amethyst label/icon — no `setTaskDescription(title, favicon, themeColor)`. | +| Add to Home screen / install | ❌ | ❌ | No `ShortcutManagerCompat.requestPinShortcut`. The launcher intent must route through the main process (the `:napplet` activity isn't exported). | +| Theme-color system bars | ❌ | ❌ | External pads the window with the app's `colorBackground`; `theme-color` is never read. | +| Page title in chrome | ⚠️ host only | ❌ fixed at launch | Neither listens to `onReceivedTitle`. | +| System back = history back | ✅ | ✅ | | +| Forward | ❌ | ❌ | Chrome's menu icon row starts with Forward. | +| Stop loading | ❌ | ❌ | Reload stays Reload while loading. | +| Out-of-scope bar (origin + ✕ close) | ❌ | ❌ | Navigating off-site silently replaces the app. There's no concept of the app's "scope" or "home". | +| `target=_blank` / `window.open` | ❌ | ❌ | `setSupportMultipleWindows(false)` + `javaScriptCanOpenWindowsAutomatically=false`: `_blank` loads in place, `window.open()` returns `null`, so OAuth popups break. | +| Non-http schemes | ⚠️ | ⚠️ | Handed to `ACTION_VIEW` on a gesture, but `intent:` URIs aren't parsed (`Intent.parseUri` + `browser_fallback_url`), so they fail. | +| JS dialogs | ❌ | ❌ | **Confirmed from source:** the framework `JsDialogHelper` only shows a dialog when `webView.context is Activity`. The embed runs on a Service context, and the external browser's WebView uses `nightThemedContext()` (a `ContextThemeWrapper` over `createConfigurationContext`, not an Activity) whenever the theme is DARK/LIGHT, which `FavoriteAppLauncher` always resolves it to. So `confirm()` returns `false` and `prompt()` returns `null` in both. | +| Permission prompts (camera/mic) | ❌ | ❌ | `onPermissionRequest` isn't overridden, so the default denies. Video calls, QR scanners and voice notes on the web all fail. | +| Geolocation | 🔒 | 🔒 | `setGeolocationEnabled(false)`. Could be offered per-origin behind consent, off under Tor. | +| Notifications / Push | ❌ | ❌ | Not available in WebView at all. Only a bridge polyfill could provide it; out of scope for v1. | +| Downloads | ❌ | ❌ | No `DownloadListener`, so download links do nothing. | +| HTML fullscreen video | ❌ | ❌ | `onShowCustomView` isn't implemented, so the fullscreen button is dead. | +| Web Share (`navigator.share`) | ❌ | ❌ | Not in WebView. Can be polyfilled through the existing document-start shim to an `ACTION_SEND` chooser. | +| Share page / Copy link | ❌ | ❌ | Not in either top pill. | +| Open in external browser | ❌ | ❌ | Useful escape hatch (Google sign-in blocks WebView user agents). | +| Find in page | ❌ | ❌ | `WebView.findAllAsync` / `findNext`. | +| Desktop site | ❌ | ❌ | UA override + `useWideViewPort`. | +| Text zoom | ❌ | ❌ | `settings.textZoom`. | +| Page info / site settings | ⚠️ | ⚠️ | "Manage permissions" covers NIP-07 grants only. No connection/cert state, no clear-site-data. | +| Long-press link/image menu | ❌ | ❌ | External can use `hitTestResult` in `onCreateContextMenu`. For the embed, the tap forwarder would need a long-press path. | +| Pull-to-refresh | ❌ | ❌ | Optional. Should respect the page's overscroll (only fire at `scrollY == 0`). | +| Error / offline page | ✅ overlay + Retry | ⚠️ | External only logs to the console and shows the raw WebView error page. | +| Renderer crash recovery | ❌ | ❌ | No `onRenderProcessGone`. A renderer crash in `:napplet` kills every WebView in that process, including every warm tab. | +| File input | ✅ | ✅ | Recently added. | +| NIP-07 `window.nostr` | ✅ | ✅ | Amethyst-only advantage. Keep it. | +| Tor per host | ✅ | ✅ | Amethyst-only advantage. | + +### Bugs found along the way (fix regardless of the redesign) + +1. **"Open full screen" opens the launch URL, not the current page.** In `WebAppScreen` it + calls `FavoriteAppLauncher.launchUrl(context, url)` with the original `url`, not + `currentUrl`. +2. **External favorite state is wrong after any navigation.** `NappletControlSheet.updateUrl` + forces `isFavorite = false`, so an already-pinned site shows "Add to favorites" and tapping + it sends a toggle that *removes* the pin. The broker knows the truth: it should push the + state back (or the toggle should be an explicit add/remove, not a blind flip). +3. **External header title never updates.** It shows the launch host even after navigating to + another site. That's misleading when combined with NIP-07 prompts. +4. **The two sheets disagree.** They use different icons (emoji vs Material symbols, `Lock` vs + `ic_tor`), different strings for the same row, a different row order, and only one side has + "Open full" and the address field. There's also a doc contradiction: `BrowserScreen` says "a + running app never carries an editable address bar", but `NappletControlSheet` renders one + in the external browser. + +## 4. Proposal + +### 4.1 One chrome spec, two renderers + +Promote `EmbeddedTabChrome` into a surface-neutral **`WebChromeSpec`** in `commons`: title, +origin, security state (https / http / onion-via-Tor / sandbox), canGoBack/Forward, +isLoading, isFavorite, theme color, and a list of typed actions. Both renderers draw from +it with the same order, icons (Material Symbols font; the Views side can draw the same glyphs +from `material_symbols_outlined.ttf` with a `Typeface`) and strings. Add a unit test that pins +the row order and visibility for each surface type (web / nsite / napplet × embed / full). + +### 4.2 Top pill (Chrome-style app menu) + +Keep the top-center grabber (it stays out of the site's avatar corner and can't be spoofed +by the page). Expanded: + +``` +┌──────────────────────────────────────────────┐ +│ [favicon] Page title [✕] │ ✕ only in external = close task +│ 🔒 example.com · via Tor │ read-only origin chip; tap → Page info +├──────────────────────────────────────────────┤ +│ ← → ↻/✕ ★ ⇪ │ back · forward · reload/stop · favorite · share +├──────────────────────────────────────────────┤ +│ ⧉ Copy link │ +│ ⬈ Open in browser app │ external browser escape hatch +│ ⛶ Open full screen / ⤓ Return to tab │ embed ↔ external +│ ⌂ Add to Home screen │ +│ 🔍 Find in page │ +│ 🖥 Desktop site [ ] │ +│ 🧅 Route over Tor [●] │ +│ ⚙ Site settings & permissions │ NIP-07 grants + camera/mic/location + clear data +│ >_ Console (N) [ ] │ under a "Developer" divider +└──────────────────────────────────────────────┘ +``` + +- **Drop the editable address field from running apps.** A PWA never has one. The origin chip + is read-only (tap = page info, long-press = copy). Typing a URL stays the Browser tab + launcher's job, which already has the omnibox and suggestions. This matches the stated + intent in `BrowserScreen`'s KDoc. +- Header title comes from `onReceivedTitle`, falling back to the host. +- The icon row mirrors Chrome's top row. Reload turns into Stop while `isLoading`. +- A **scope indicator**: when the current origin differs from the app's start origin, tint the + origin chip and show a "Back to " action (the in-app version of Chrome's out-of-scope + bar). + +### 4.3 Bottom pill + +- Keep it **developer-only** (Console), but move the toggle under a *Developer* divider in the + top pill, and let the grabber show an error-count badge so the user knows why to open it. +- Unify height: 40 % of the surface in both renderers (Views currently hard-code 220 dp). +- **Find-in-page** reuses the bottom slot: a bar with the query field, `n/m`, ↑ ↓ and ✕ docked + where the console grabber sits (Chrome puts find-in-page at the top, but the top edge here + belongs to the grabber, and the bottom avoids covering the page's own header). Only one + bottom panel is shown at a time. +- Out-of-scope navigation can also surface a slim bottom "← Back to " pill. Pick either + this or the chip in 4.2 after trying both on a device. + +### 4.4 Behaviours (WebView plumbing) + +Fix these in `NappletBrowserActivity`, `NappletBrowserService`, and where applicable the +nsite/napplet hosts (sandbox profile permitting). + +1. **JS dialogs:** implement `onJsAlert`/`onJsConfirm`/`onJsPrompt`/`onJsBeforeUnload` ourselves. + - External: show an Activity-owned dialog titled "*origin* says". + - Embed: IPC to the main process and show a Compose dialog over the tab. + - Sandboxed napplets keep today's auto-cancel. +2. **Popups / `_blank`:** enable `setSupportMultipleWindows(true)` and handle `onCreateWindow`. + A user-gesture `window.open` becomes a transient child WebView (an OAuth popup, with + `opener` intact) shown as a sheet with origin + ✕. A plain `_blank` link opens a new + external-browser task. Keep auto-open without a gesture blocked. +3. **`intent:` URIs:** parse them with `Intent.parseUri(..., URI_INTENT_SCHEME)`, strip the + component/selector, and fall back to `browser_fallback_url`. Keep the gesture requirement. +4. **Downloads:** add a `DownloadListener`. Hand off to the main process, which runs + `DownloadManager` (through the Tor proxy when the host is on Tor, or else refuses rather + than leaking the download). Handle `blob:`/`data:` via the shim. +5. **Permissions:** handle `onPermissionRequest` (camera/mic) and optionally geolocation. The + consent prompt runs in the main process (same pattern as NIP-07 consent), is stored per + origin in the existing Connected Apps ledger, and requests the Android runtime permission + from the main activity. Geolocation stays off by default and off under Tor. +6. **Fullscreen:** implement `onShowCustomView`/`onHideCustomView`. + - External: swap in the custom view with immersive system bars. + - Embed: open the external browser in fullscreen, since a streamed surface can't take over + the window. +7. **Web Share polyfill:** have the document-start shim define `navigator.share`/`canShare` + routed over the existing bridge to an `ACTION_SEND` chooser (text/url, files later). + Require a user activation. +8. **Theme color:** a tiny shim observer reports `` (and changes to + it) over the bridge. External tints the status/nav bar padding and uses it in + `setTaskDescription`. Embed tints the top grabber. +9. **Task description (external):** call `setTaskDescription(title, favicon, themeColor)` on + title, icon and theme changes, so Recents looks like an installed app. +10. **Add to Home screen:** `ShortcutManagerCompat.requestPinShortcut`, with the favicon (from + `BrowserIconRegistry`) as the icon and an intent to an exported main-process trampoline + that calls `FavoriteAppLauncher.launchUrl`. Offer "open as tab" vs "open full screen". + Also add dynamic shortcuts for the top favorites on launcher long-press. +11. **Renderer crash:** handle `onRenderProcessGone`. Destroy that WebView, return `true`, and + show the error overlay with Retry (for the embed, rebuild the session on Retry). +12. **Context menu (external first):** on long-press over a link or image, offer Open in new + window, Copy link, Share link, Download image. +13. **Desktop site / text zoom:** per-host settings persisted next to `WebAppNetworkRegistry`. +14. **Pull-to-refresh (optional):** only when the page is at `scrollY == 0` and hasn't claimed + overscroll. + +### 4.5 Deliberately different from Chrome (keep) + +- Keyless `:napplet` process, per-origin NIP-07 consent, per-account WebView profile, Tor + routing per host. These are Amethyst's reason to exist and none of the above weakens them. + Every new capability goes through the broker with the same consent + ledger pattern. +- Notifications/Push: not feasible in WebView without a service-worker bridge. Out of scope. +- Service-worker offline: WebView already supports SW. Nothing to do beyond not clearing the + profile. + +## 5. Phasing + +| Phase | Contents | Size | +|---|---|---| +| **0: bugs** | §3 bugs 1–3; `onReceivedTitle`; `onRenderProcessGone` | S | +| **1: chrome unification** | `WebChromeSpec` in commons; both renderers; icon row (back/forward/reload-stop/star/share); Copy link; Open in browser app; Return to tab; remove the external address field; row-order test | M | +| **2: dead web APIs** | JS dialogs, `_blank`/`window.open` popups, `intent:` URIs, downloads, fullscreen video, Web Share polyfill | M–L | +| **3: OS integration** | theme-color bars, `setTaskDescription`, Add to Home screen + trampoline, dynamic shortcuts | M | +| **4: permissions & page info** | camera/mic/geo consent via the broker, site settings page (grants + clear data + connection state), Find in page, Desktop site, text zoom | L | +| **5: polish** | long-press context menu, pull-to-refresh, out-of-scope indicator | M | + +## 6. Open questions + +1. Should running apps lose the editable address bar entirely (the PWA model), or keep it + behind a "Go to address…" row? +2. Where does the user land on a new-window or `_blank` link from an **embedded** tab: a new + external task (Chrome PWA behaviour) or a transient sheet over the tab? +3. Camera/mic/geo: allow over Tor at all? (WebRTC can leak the real IP; geolocation defeats + the point.) The proposal is to deny both when the host is on Tor. +4. Add to Home screen: should the shortcut open as an in-app tab or as the external + standalone window by default? From 684b9ba0a4191d79baaab9bcb36494287627e0e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:53:38 +0000 Subject: [PATCH 02/14] docs(plans): record browser PWA-parity decisions Address bar hidden behind an 'Edit address' row, new windows follow Chrome (new full-screen Amethyst browser window, opener kept), camera/mic/location consent-gated on Tor and open web alike, home-screen shortcuts open in Amethyst's own browser via a main-process trampoline. Phase 0 marked done; renderer-crash handling moved to phase 2 since every :napplet WebView owner must handle it together. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-pwa-parity.md | 84 ++++++++++++------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/amethyst/plans/2026-09-26-browser-pwa-parity.md b/amethyst/plans/2026-09-26-browser-pwa-parity.md index e9c658cf62..0321a63f50 100644 --- a/amethyst/plans/2026-09-26-browser-pwa-parity.md +++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md @@ -1,6 +1,6 @@ # Browser surfaces → PWA parity review -Status: **review / proposal** (no code changed yet). Scope: every surface that renders a +Status: **review / proposal**, decisions recorded in §6. Scope: every surface that renders a plain web client — the **embedded** bottom-bar tab and the **external** full-screen browser — plus their shared top pull-down "pill" and bottom console "pill". The nsite/napplet hosts reuse the same chrome and are covered where the change is shared. @@ -98,7 +98,7 @@ Legend: ✅ parity · ⚠️ partial · ❌ missing · 🔒 intentionally blocke | Non-http schemes | ⚠️ | ⚠️ | Handed to `ACTION_VIEW` on a gesture, but `intent:` URIs aren't parsed (`Intent.parseUri` + `browser_fallback_url`), so they fail. | | JS dialogs | ❌ | ❌ | **Confirmed from source:** the framework `JsDialogHelper` only shows a dialog when `webView.context is Activity`. The embed runs on a Service context, and the external browser's WebView uses `nightThemedContext()` (a `ContextThemeWrapper` over `createConfigurationContext`, not an Activity) whenever the theme is DARK/LIGHT, which `FavoriteAppLauncher` always resolves it to. So `confirm()` returns `false` and `prompt()` returns `null` in both. | | Permission prompts (camera/mic) | ❌ | ❌ | `onPermissionRequest` isn't overridden, so the default denies. Video calls, QR scanners and voice notes on the web all fail. | -| Geolocation | 🔒 | 🔒 | `setGeolocationEnabled(false)`. Could be offered per-origin behind consent, off under Tor. | +| Geolocation | ❌ | ❌ | `setGeolocationEnabled(false)`. To be offered per origin behind consent, on Tor and open web alike (§6). | | Notifications / Push | ❌ | ❌ | Not available in WebView at all. Only a bridge polyfill could provide it; out of scope for v1. | | Downloads | ❌ | ❌ | No `DownloadListener`, so download links do nothing. | | HTML fullscreen video | ❌ | ❌ | `onShowCustomView` isn't implemented, so the fullscreen button is dead. | @@ -158,6 +158,7 @@ by the page). Expanded: │ ← → ↻/✕ ★ ⇪ │ back · forward · reload/stop · favorite · share ├──────────────────────────────────────────────┤ │ ⧉ Copy link │ +│ ✎ Edit address │ rare: reveals the editable URL field │ ⬈ Open in browser app │ external browser escape hatch │ ⛶ Open full screen / ⤓ Return to tab │ embed ↔ external │ ⌂ Add to Home screen │ @@ -169,10 +170,11 @@ by the page). Expanded: └──────────────────────────────────────────────┘ ``` -- **Drop the editable address field from running apps.** A PWA never has one. The origin chip - is read-only (tap = page info, long-press = copy). Typing a URL stays the Browser tab - launcher's job, which already has the omnibox and suggestions. This matches the stated - intent in `BrowserScreen`'s KDoc. +- **Hide the editable address field by default** (decision 1). A PWA never shows one. The + origin chip is read-only (tap = page info, long-press = copy). An **Edit address** row, in + both the embed and the external browser, swaps the chip for the editable field with + the URL pre-selected; Go navigates and collapses it back to the chip. Expected to be used + rarely; the Browser tab launcher stays the main place to type a URL. - Header title comes from `onReceivedTitle`, falling back to the host. - The icon row mirrors Chrome's top row. Reload turns into Stop while `isLoading`. - A **scope indicator**: when the current origin differs from the app's start origin, tint the @@ -197,22 +199,32 @@ Fix these in `NappletBrowserActivity`, `NappletBrowserService`, and where applic nsite/napplet hosts (sandbox profile permitting). 1. **JS dialogs:** implement `onJsAlert`/`onJsConfirm`/`onJsPrompt`/`onJsBeforeUnload` ourselves. - - External: show an Activity-owned dialog titled "*origin* says". + - External: show an Activity-owned dialog titled "*origin* says" (the framework helper + can't, because the WebView's themed context isn't an Activity). - Embed: IPC to the main process and show a Compose dialog over the tab. - Sandboxed napplets keep today's auto-cancel. -2. **Popups / `_blank`:** enable `setSupportMultipleWindows(true)` and handle `onCreateWindow`. - A user-gesture `window.open` becomes a transient child WebView (an OAuth popup, with - `opener` intact) shown as a sheet with origin + ✕. A plain `_blank` link opens a new - external-browser task. Keep auto-open without a gesture blocked. +2. **Popups / `_blank` — follow Chrome** (decision 2): enable `setSupportMultipleWindows(true)` + and handle `onCreateWindow`, from both the embed and the external browser. + - Every new window (a `_blank` link or a user-gesture `window.open`) opens as a **new + full-screen Amethyst browser window** (a new `NappletBrowserActivity` task), the way + Chrome opens a new tab. + - `opener` must survive for OAuth popups. The child WebView is created in + `onCreateWindow` (same `:napplet` process as both parents) and handed to the new + activity through an in-process registry keyed by a one-shot token passed in the + intent. `window.close()` from the child (`onCloseWindow`) finishes that task and + returns the user to the opener. + - Keep auto-open without a user gesture blocked (Chrome's popup blocker). 3. **`intent:` URIs:** parse them with `Intent.parseUri(..., URI_INTENT_SCHEME)`, strip the component/selector, and fall back to `browser_fallback_url`. Keep the gesture requirement. 4. **Downloads:** add a `DownloadListener`. Hand off to the main process, which runs `DownloadManager` (through the Tor proxy when the host is on Tor, or else refuses rather than leaking the download). Handle `blob:`/`data:` via the shim. -5. **Permissions:** handle `onPermissionRequest` (camera/mic) and optionally geolocation. The - consent prompt runs in the main process (same pattern as NIP-07 consent), is stored per - origin in the existing Connected Apps ledger, and requests the Android runtime permission - from the main activity. Geolocation stays off by default and off under Tor. +5. **Permissions:** handle `onPermissionRequest` (camera/mic) and + `onGeolocationPermissionsShowPrompt` (enable geolocation). The consent prompt runs in the + main process (same pattern as NIP-07 consent), is stored per origin in the existing + Connected Apps ledger, and requests the Android runtime permission from the main + activity. Works the same on **Tor and the open web** (decision 3); nothing is + auto-denied because of routing. 6. **Fullscreen:** implement `onShowCustomView`/`onHideCustomView`. - External: swap in the custom view with immersive system bars. - Embed: open the external browser in fullscreen, since a streamed surface can't take over @@ -225,12 +237,19 @@ nsite/napplet hosts (sandbox profile permitting). `setTaskDescription`. Embed tints the top grabber. 9. **Task description (external):** call `setTaskDescription(title, favicon, themeColor)` on title, icon and theme changes, so Recents looks like an installed app. -10. **Add to Home screen:** `ShortcutManagerCompat.requestPinShortcut`, with the favicon (from - `BrowserIconRegistry`) as the icon and an intent to an exported main-process trampoline - that calls `FavoriteAppLauncher.launchUrl`. Offer "open as tab" vs "open full screen". - Also add dynamic shortcuts for the top favorites on launcher long-press. +10. **Add to Home screen** (decision 4): `ShortcutManagerCompat.requestPinShortcut`, with the + favicon (from `BrowserIconRegistry`) as the icon. The shortcut always opens the page + **full screen in Amethyst's own browser** (`NappletBrowserActivity`), never the system + browser, so the NIP-07 signer is there. Its intent targets an exported **main-process** + trampoline activity (the `:napplet` activities stay unexported). The trampoline + brings up the broker, the Tor port and the account's WebView profile, then calls + `FavoriteAppLauncher.launchUrl` and finishes. Also add dynamic shortcuts for the top + favorites on launcher long-press, with the same target. 11. **Renderer crash:** handle `onRenderProcessGone`. Destroy that WebView, return `true`, and - show the error overlay with Retry (for the embed, rebuild the session on Retry). + show the error overlay with Retry (for the embed, rebuild the session on Retry). All + WebViews in `:napplet` share one renderer, and the process is still killed if **any** of + them leaves the callback unhandled. So `NappletBrowserActivity`, `NappletBrowserService`, + `NappletHostActivity` and `NappletHostService` must all ship the handler in one change. 12. **Context menu (external first):** on long-press over a link or image, offer Open in new window, Copy link, Share link, Download image. 13. **Desktop site / text zoom:** per-host settings persisted next to `WebAppNetworkRegistry`. @@ -250,20 +269,21 @@ nsite/napplet hosts (sandbox profile permitting). | Phase | Contents | Size | |---|---|---| -| **0: bugs** | §3 bugs 1–3; `onReceivedTitle`; `onRenderProcessGone` | S | -| **1: chrome unification** | `WebChromeSpec` in commons; both renderers; icon row (back/forward/reload-stop/star/share); Copy link; Open in browser app; Return to tab; remove the external address field; row-order test | M | -| **2: dead web APIs** | JS dialogs, `_blank`/`window.open` popups, `intent:` URIs, downloads, fullscreen video, Web Share polyfill | M–L | +| **0: bugs** ✅ | §3 bugs 1–3; live page titles (`onReceivedTitle`) in both surfaces; JS dialogs in the external browser (origin-labelled, with "Block dialogs from this page") | S | +| **1: chrome unification** | `WebChromeSpec` in commons; both renderers; icon row (back/forward/reload-stop/star/share); Copy link; Open in browser app; Return to tab; address field hidden behind "Edit address"; row-order test | M | +| **2: dead web APIs** | `onRenderProcessGone` (all four WebView owners at once — see 4.4 #11), JS dialogs in the embed, `_blank`/`window.open` → new browser window, `intent:` URIs, downloads, fullscreen video, Web Share polyfill | M–L | | **3: OS integration** | theme-color bars, `setTaskDescription`, Add to Home screen + trampoline, dynamic shortcuts | M | | **4: permissions & page info** | camera/mic/geo consent via the broker, site settings page (grants + clear data + connection state), Find in page, Desktop site, text zoom | L | | **5: polish** | long-press context menu, pull-to-refresh, out-of-scope indicator | M | -## 6. Open questions +## 6. Decisions (2026-09-26) -1. Should running apps lose the editable address bar entirely (the PWA model), or keep it - behind a "Go to address…" row? -2. Where does the user land on a new-window or `_blank` link from an **embedded** tab: a new - external task (Chrome PWA behaviour) or a transient sheet over the tab? -3. Camera/mic/geo: allow over Tor at all? (WebRTC can leak the real IP; geolocation defeats - the point.) The proposal is to deny both when the host is on Tor. -4. Add to Home screen: should the shortcut open as an in-app tab or as the external - standalone window by default? +1. **Address bar:** hidden by default in running apps; an "Edit address" row reveals the + editable field. Rarely used. +2. **New windows (`_blank`, `window.open`):** follow Chrome. They open a new full-screen + Amethyst browser window, keeping `opener` for OAuth popups. +3. **Camera / mic / location:** consent-gated per origin, and they work on **both Tor and + the open web**. Routing never auto-denies them. +4. **Add to Home screen:** the shortcut opens the page full screen in **Amethyst's browser** + (not the system browser), so the signer is present. It launches through a main-process + trampoline. From 202b780f7b6ada824d47998b566d6556dfbfe2b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 14:53:41 +0000 Subject: [PATCH 03/14] =?UTF-8?q?fix(browser):=20phase=200=20of=20PWA=20pa?= =?UTF-8?q?rity=20=E2=80=94=20dialogs,=20titles,=20favorite=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Full-screen browser: show page alert/confirm/prompt/beforeunload dialogs ourselves. The framework only shows them when the WebView's context is an Activity, and ours is a configuration context, so they were silently cancelled (confirm() always false). Dialogs are titled with the page's host and offer 'Block dialogs from this page' from the second one on, reset on the next main-frame load. - Full-screen browser: the star no longer resets to 'Add' on every navigation (which made tapping it remove an existing pin). It asks the broker for the real state (MSG_QUERY_WEB_FAVORITE / MSG_WEB_FAVORITE_STATE) and the toggle now sends the explicit target state. - Both surfaces: the top sheet shows the page's , falling back to the current host, instead of the launch-time host. - Embedded tab: 'Open full screen' opens the page being viewed, not the URL the tab was pinned with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../amethyst/napplet/NappletBrokerService.kt | 44 ++++- .../browser/EmbeddedWebAppController.kt | 7 +- .../screen/loggedIn/browser/WebAppScreen.kt | 16 +- .../amethyst/napplethost/BrowserJsDialogs.kt | 181 ++++++++++++++++++ .../napplethost/NappletBrowserActivity.kt | 101 ++++++++-- .../napplethost/NappletBrowserContract.kt | 6 +- .../napplethost/NappletBrowserService.kt | 13 +- .../napplethost/NappletControlSheet.kt | 75 ++++++-- .../amethyst/napplethost/NappletIpc.kt | 25 ++- nappletHost/src/main/res/values/strings.xml | 8 + 10 files changed, 429 insertions(+), 47 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index de5815af52..ed067f5c1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -218,18 +218,34 @@ class NappletBrokerService : Service() { return true } - // The direct-WebView browser requests a favorite toggle for the current URL (main process only). + // The direct-WebView browser pins/unpins the page it shows (main process only). if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) { val data = msg.data ?: return true val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url } FavoriteAppsRegistry.init(applicationContext) val id = "url:$url" - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) - } else { + val target = + if (data.containsKey(NappletIpc.KEY_FAVORITE_IS_FAVORITE)) { + data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE) + } else { + !FavoriteAppsRegistry.isFavorite(id) + } + if (target) { FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) + } else { + FavoriteAppsRegistry.remove(id) } + msg.replyTo?.let { replyWebFavoriteState(it, url) } + return true + } + + // The direct-WebView browser asks whether the page it now shows is pinned, so its star is right. + if (msg.what == NappletIpc.MSG_QUERY_WEB_FAVORITE) { + val replyTo = msg.replyTo ?: return true + val url = msg.data?.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true + FavoriteAppsRegistry.init(applicationContext) + replyWebFavoriteState(replyTo, url) return true } @@ -460,6 +476,26 @@ class NappletBrokerService : Service() { } } + /** Tells a browser surface whether [url] is currently pinned, so its star shows the registry's truth. */ + private fun replyWebFavoriteState( + replyTo: Messenger, + url: String, + ) { + val message = + Message.obtain(null, NappletIpc.MSG_WEB_FAVORITE_STATE).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_FAVORITE_URL, url) + putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, FavoriteAppsRegistry.isFavorite("url:$url")) + } + } + try { + replyTo.send(message) + } catch (e: RemoteException) { + Log.w("NappletBrokerService", "Browser went away before the favorite state could be delivered", e) + } + } + /** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */ private fun push( replyTo: Messenger, 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 3858281234..3af22b5bbc 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 @@ -106,8 +106,8 @@ class EmbeddedWebAppController( // previous view can never reap the replacement. private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" - /** Invoked on the main thread when the page navigates: (url, canGoBack). */ - var onUrlChanged: ((String, Boolean) -> Unit)? = null + /** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack). */ + var onUrlChanged: ((String, String?, Boolean) -> Unit)? = null override var onImeEvent: ((ImeEvent) -> Unit)? = null @@ -235,7 +235,8 @@ class EmbeddedWebAppController( NappletBrowserContract.MSG_URL_CHANGED -> { val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false - onUrlChanged?.invoke(url, canGoBack) + val title = msg.data?.getString(NappletBrowserContract.KEY_TITLE) + onUrlChanged?.invoke(url, title, canGoBack) } NappletBrowserContract.MSG_IME_EVENT -> { val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true 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 8d0c221e5a..4c67b7334c 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 @@ -103,6 +103,8 @@ private fun EmbeddedWebAppTab( val id = "url:$url" var currentUrl by remember { mutableStateOf(url) } + // The page's own <title>; null until the current document reports one (the sheet shows the host). + var pageTitle by remember { mutableStateOf<String?>(null) } var canGoBack by remember { mutableStateOf(false) } val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } @@ -124,20 +126,24 @@ private fun EmbeddedWebAppTab( // Keep the URL/back callback fresh (cheap, needs the latest closure). SideEffect { - controller.onUrlChanged = { newUrl, back -> - if (newUrl != "about:blank") currentUrl = newUrl + controller.onUrlChanged = { newUrl, title, back -> + if (newUrl != "about:blank") { + currentUrl = newUrl + pageTitle = title + } canGoBack = back } } // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, torOn, proxyAvailable, isFavorite, controller) { + remember(currentUrl, pageTitle, torOn, proxyAvailable, isFavorite, controller) { EmbeddedTabChrome( - title = hostLabel(currentUrl), + title = pageTitle ?: hostLabel(currentUrl), isSandbox = false, onReload = { controller.reload() }, - onOpenFull = { FavoriteAppLauncher.launchUrl(context, url) }, + // The page the user is looking at, not the one the tab was pinned with. + onOpenFull = { FavoriteAppLauncher.launchUrl(context, currentUrl) }, torOn = if (proxyAvailable) torOn else null, onToggleTor = { torOn = !torOn diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt new file mode 100644 index 0000000000..69ded7e3e3 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt @@ -0,0 +1,181 @@ +/* + * 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 + +/** + * 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(R.string.napplet_js_leave_title) + setMessage(R.string.napplet_js_leave_message) + setPositiveButton(R.string.napplet_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(R.string.napplet_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(R.string.napplet_js_dialog_title, host) + } else { + activity.getString(R.string.napplet_js_dialog_title_generic) + } + } +} 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 8780d82a73..473ccb0e6f 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -37,6 +37,8 @@ import android.view.Gravity import android.view.View import android.view.ViewGroup import android.webkit.ConsoleMessage +import android.webkit.JsPromptResult +import android.webkit.JsResult import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -110,6 +112,13 @@ class NappletBrowserActivity : ComponentActivity() { private var mainFrameLoadFailed = false private var lastIconHost: String? = null + // The last URL whose pin state was asked of the broker, so the several page callbacks that report the + // same address don't each trigger a round-trip. + private var lastFavoriteQueryUrl: String? = null + + // Page-originated alert/confirm/prompt/beforeunload, labelled with the page's origin. + private val jsDialogs by lazy { BrowserJsDialogs(this) } + // ---- HTML file input (`<input type="file">`) ---- // Registered as a field so it is in place before onCreate returns, which is what // registerForActivityResult requires. This activity hosts its WebView directly, so it can run the @@ -306,6 +315,8 @@ class NappletBrowserActivity : ComponentActivity() { // A picker still up when the browser is torn down would otherwise leave its callback unanswered. 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() if (this::webView.isInitialized) { // Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to // the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER @@ -399,6 +410,46 @@ class NappletBrowserActivity : ComponentActivity() { updateLoadProgress(newProgress) } + override fun onReceivedTitle( + view: WebView, + title: String?, + ) { + controlSheet?.updateTitle(title) + } + + // 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. + override fun onJsAlert( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = jsDialogs.alert(url, message, result) + + override fun onJsConfirm( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = jsDialogs.confirm(url, message, result) + + override fun onJsPrompt( + view: WebView, + url: String?, + message: String?, + defaultValue: String?, + result: JsPromptResult, + ): Boolean = jsDialogs.prompt(url, message, defaultValue, result) + + override fun onJsBeforeUnload( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = jsDialogs.beforeUnload(result) + override fun onReceivedIcon( view: WebView, icon: Bitmap?, @@ -468,7 +519,9 @@ class NappletBrowserActivity : ComponentActivity() { mainFrameLoadFailed = false // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. if (OmniboxInput.hostOf(url) != lastIconHost) lastIconHost = null - controlSheet?.updateUrl(url) + // Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it. + jsDialogs.onMainFrameNavigation() + showUrl(url) } override fun onReceivedError( @@ -497,7 +550,7 @@ class NappletBrowserActivity : ComponentActivity() { // The page has painted its first frame — drop the loading screen. loadingView?.let { contentFrame.removeView(it) } loadingView = null - controlSheet?.updateUrl(url) + showUrl(url) } override fun doUpdateVisitedHistory( @@ -506,7 +559,7 @@ class NappletBrowserActivity : ComponentActivity() { isReload: Boolean, ) { syncBackState() - controlSheet?.updateUrl(url) + showUrl(url) } override fun onPageFinished( @@ -514,7 +567,7 @@ class NappletBrowserActivity : ComponentActivity() { url: String, ) { syncBackState() - controlSheet?.updateUrl(url) + 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://"))) { recordHistory(url, view.title) @@ -523,6 +576,22 @@ class NappletBrowserActivity : ComponentActivity() { } } + /** + * Pushes the displayed [url] into the chrome and, when it is a different page, asks the broker whether + * 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) + if (url == lastFavoriteQueryUrl) return + lastFavoriteQueryUrl = url + val msg = + Message.obtain(null, NappletIpc.MSG_QUERY_WEB_FAVORITE).apply { + replyTo = replyMessenger + data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) } + } + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + } + /** * Second-chance favicon capture for pages `onReceivedIcon` never fires for (SVG-only declarations — * WebView does not rasterize those into the callback). Deliberately delayed so the WebView's own @@ -677,6 +746,10 @@ class NappletBrowserActivity : ComponentActivity() { val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true bridgeReplyProxy?.postMessage(payload) } + 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)) + } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true @@ -754,7 +827,7 @@ class NappletBrowserActivity : ComponentActivity() { onNavigate = { loadAddress(it) }, onConsole = { show -> consolePanel?.setShowing(show) }, isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), - onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) }, + onFavoriteToggle = { url, isFavorite -> sendFavoriteToggle(url, isFavorite) }, ).also { controlSheet = it } /** @@ -775,19 +848,23 @@ class NappletBrowserActivity : ComponentActivity() { if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) } - private fun sendFavoriteToggle(url: String) { - val host = - runCatching { - android.net.Uri - .parse(url) - .host - }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + /** + * Pins or unpins [url] in the main-process registry. Sends the state the user asked for rather than a + * flip, and takes the broker's confirmed state back through [NappletIpc.MSG_WEB_FAVORITE_STATE]. + */ + private fun sendFavoriteToggle( + url: String, + isFavorite: Boolean, + ) { + val host = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url val msg = Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply { + replyTo = replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) putString(NappletIpc.KEY_FAVORITE_LABEL, host) + putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, isFavorite) } } if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) 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 4725eb7074..c4d2ba760a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -50,7 +50,10 @@ object NappletBrowserContract { /** Client → provider: route this session over Tor ([KEY_USE_TOR]) or the open web. */ const val MSG_SET_TOR = 6 - /** Provider → client: the page navigated; carries [KEY_URL] and [KEY_CAN_GO_BACK]. */ + /** + * Provider → client: the page navigated or retitled; carries [KEY_URL], [KEY_CAN_GO_BACK] and, once the + * document has one, its `<title>` in [KEY_TITLE] (absent while a new page is still loading). + */ const val MSG_URL_CHANGED = 7 /** @@ -152,6 +155,7 @@ object NappletBrowserContract { const val KEY_USE_TOR = "useTor" const val KEY_CORE_LIB_INFO = "coreLibInfo" const val KEY_CAN_GO_BACK = "canGoBack" + const val KEY_TITLE = "title" /** * ARGB of Amethyst's theme background, passed from the main process. The WebView (and the surface 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 474de861fd..665414d757 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -386,6 +386,12 @@ class NappletBrowserService : Service() { recordIcon(host, icon) } + /** Relays the page's `<title>` so the tab's top sheet names the page, not just its host. */ + override fun onReceivedTitle( + view: WebView, + title: String?, + ) = pushUrl(tab, view) + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { if (tab == null) return false pushConsoleLog( @@ -477,7 +483,8 @@ class NappletBrowserService : Service() { tab?.loadFailed = false // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. if (tab != null && OmniboxInput.hostOf(url) != tab.lastIconHost) tab.lastIconHost = null - pushUrl(tab, view) + // view.title still names the page being left; the new one's arrives via onReceivedTitle. + pushUrl(tab, view, includeTitle = false) pushLoadState(tab, view, isLoading = true) } @@ -530,14 +537,18 @@ class NappletBrowserService : Service() { private fun pushUrl( tab: BrowserTab?, view: WebView, + includeTitle: Boolean = true, ) { val url = view.url ?: return + // WebView reports the URL itself as the title of a document that has none yet; that is no title. + val title = view.title?.trim()?.takeIf { includeTitle && it.isNotEmpty() && it != url } val message = Message.obtain(null, NappletBrowserContract.MSG_URL_CHANGED).apply { data = Bundle().apply { putString(NappletBrowserContract.KEY_URL, url) putBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, view.canGoBack()) + title?.let { putString(NappletBrowserContract.KEY_TITLE, it) } } } runCatching { tab?.clientMessenger?.send(message) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt index 28539b2af2..cdc8215eb2 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -26,6 +26,7 @@ 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 @@ -37,6 +38,7 @@ import android.widget.LinearLayout import android.widget.Switch import android.widget.TextView import androidx.core.content.ContextCompat +import androidx.core.net.toUri import com.vitorpamplona.amethyst.commons.R as CommonsR /** @@ -52,7 +54,7 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR @SuppressLint("UseSwitchCompatOrMaterialCode") // plain framework Switch: :nappletHost is Compose/Material-free class NappletControlSheet( context: Context, - private val title: String, + title: String, private val isSandbox: Boolean, private val onReload: () -> Unit, torInitiallyOn: Boolean?, @@ -80,12 +82,15 @@ class NappletControlSheet( private val surface = resolveThemeColor(android.R.attr.colorBackground) private var expanded = false + private var title = title private var torOn = torInitiallyOn private var currentUrl = liveUrl private var isFavorite = isFavoriteInitially private var consoleShowing = false private val panel: LinearLayout + private var titleView: TextView? = null + private var grabber: View? = null private var torLabel: TextView? = null private var torSwitch: Switch? = null private var addressField: EditText? = null @@ -99,7 +104,7 @@ class NappletControlSheet( gravity = Gravity.CENTER_HORIZONTAL panel = buildPanel().also { addView(it) } - addView(buildGrabber()) + addView(buildGrabber().also { grabber = it }) } private fun buildPanel(): LinearLayout = @@ -226,13 +231,15 @@ class NappletControlSheet( }, ) addView( - TextView(context).apply { - text = title - setTextColor(onSurface) - textSize = 16f - maxLines = 1 - setPadding(dp(10), 0, 0, 0) - }, + TextView(context) + .apply { + text = title + setTextColor(onSurface) + textSize = 16f + maxLines = 1 + ellipsize = TextUtils.TruncateAt.END + setPadding(dp(10), 0, 0, 0) + }.also { titleView = it }, ) } @@ -304,23 +311,57 @@ class NappletControlSheet( } } - /** Refreshes the address bar + security glyph as the page navigates. No-op without an address row. */ + /** + * Refreshes the address bar + security glyph as the page navigates. No-op without an address row. + * Moving to another site also swaps the title for that site's host until its page title arrives + * ([updateTitle]), so the sheet never names the site the user already left. + */ fun updateUrl(url: String) { + val previous = currentUrl currentUrl = url // Don't fight the user while they're editing the field. addressField?.takeIf { !it.hasFocus() }?.setText(url) securityGlyph?.text = securityGlyphFor(url) - // Reset favorite state for the new URL (we don't know if it's a favorite without a round-trip). - if (onFavoriteToggle != null) { - isFavorite = false - favoriteLabel?.text = context.getString(R.string.browser_favorite_add) - } + if (hostOf(url) != previous?.let(::hostOf)) setTitleText(hostOf(url) ?: url) + // A different page: its pin state is unknown until the host answers through [setFavorite]. Show + // "Add" meanwhile — the toggle sends the explicit target state, so even a tap in that window can + // only ever add (idempotent), never silently remove an existing pin. + if (url != previous && onFavoriteToggle != null) applyFavorite(false) } + /** + * Shows the page's own `<title>`, falling back to the current host when the page has none (WebView + * reports the URL itself as the title of an untitled document). + */ + fun updateTitle(pageTitle: String?) { + val real = pageTitle?.trim()?.takeIf { it.isNotEmpty() && it != currentUrl } + setTitleText(real ?: currentUrl?.let(::hostOf) ?: return) + } + + /** Applies the registry's answer for [url]; ignored when the user has already moved to another page. */ + fun setFavorite( + url: String, + favorite: Boolean, + ) { + if (url == currentUrl) applyFavorite(favorite) + } + + private fun applyFavorite(favorite: Boolean) { + isFavorite = favorite + favoriteLabel?.text = context.getString(if (favorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + } + + private fun setTitleText(text: String) { + title = text + titleView?.text = text + grabber?.contentDescription = text + } + + private fun hostOf(url: String): String? = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } + private fun toggleFavorite() { val url = currentUrl?.takeIf { it.isNotBlank() } ?: return - isFavorite = !isFavorite - favoriteLabel?.text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) + applyFavorite(!isFavorite) collapse() onFavoriteToggle?.invoke(url, isFavorite) } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index 8611a0d8cc..647c329cf8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -90,10 +90,11 @@ object NappletIpc { const val MSG_RECORD_ICON = 10 /** - * Host → broker (browser mode): toggle a URL in the main-process favorites registry. Carries - * [KEY_FAVORITE_URL] and [KEY_FAVORITE_LABEL]. The broker adds the URL if it isn't already - * a favorite, or removes it if it is — identical to the in-app star toggle on the home screen. - * Fire-and-forget; no reply needed. + * Host → broker (browser mode): add or remove a URL in the main-process favorites registry. Carries + * [KEY_FAVORITE_URL], [KEY_FAVORITE_LABEL] and [KEY_FAVORITE_IS_FAVORITE] (the state the user asked + * for). The explicit target matters: a blind flip against a stale chrome would remove a pin the user + * meant to add. Without [KEY_FAVORITE_IS_FAVORITE] the broker falls back to flipping the current + * state. When `replyTo` is set, the broker answers with [MSG_WEB_FAVORITE_STATE]. */ const val MSG_TOGGLE_WEB_FAVORITE = 11 @@ -120,6 +121,19 @@ object NappletIpc { */ const val MSG_RELEASE_CLIENT = 13 + /** + * Host → broker (browser mode): is [KEY_FAVORITE_URL] a favorite? Sent whenever the displayed page + * changes, so the star reflects the registry instead of guessing. The broker answers `replyTo` with + * [MSG_WEB_FAVORITE_STATE]; it keeps no reference to the Messenger. + */ + const val MSG_QUERY_WEB_FAVORITE = 14 + + /** + * Broker → host: the favorite state of [KEY_FAVORITE_URL] ([KEY_FAVORITE_IS_FAVORITE]). The reply to + * [MSG_QUERY_WEB_FAVORITE], and to a [MSG_TOGGLE_WEB_FAVORITE] that carried a `replyTo`. + */ + const val MSG_WEB_FAVORITE_STATE = 15 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" @@ -144,6 +158,9 @@ object NappletIpc { /** A human-readable label for the favorited URL (typically the host). */ const val KEY_FAVORITE_LABEL = "favoriteLabel" + /** Boolean: whether [KEY_FAVORITE_URL] is (or should become) a favorite. */ + const val KEY_FAVORITE_IS_FAVORITE = "favoriteIsFavorite" + /** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */ const val KEY_FOREGROUND = "foreground" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 34c48f60bf..2bc1944e43 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -36,4 +36,12 @@ <!-- Developer console: page-load failures surfaced as console errors --> <string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string> <string name="napplet_console_http_error">HTTP %1$d %2$s</string> + + <!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) in the browser --> + <string name="napplet_js_dialog_title">%1$s says</string> + <string name="napplet_js_dialog_title_generic">This page says</string> + <string name="napplet_js_dialog_block">Block dialogs from this page</string> + <string name="napplet_js_leave_title">Leave site?</string> + <string name="napplet_js_leave_message">Changes you made may not be saved.</string> + <string name="napplet_js_leave">Leave</string> </resources> From 68079cf3cc8f5df95d78b8b61dc24697d5dc22ee Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 16:15:34 +0000 Subject: [PATCH 04/14] feat(browser): shared PWA-style chrome spec and site-permission model BrowserChrome decides which actions the browser top pill shows and in what order for every surface (web/nsite/napplet x embedded/full screen), plus security badge, scope check, text-zoom steps, desktop user agent and theme-color parsing (BrowserChromeTest). BrowserSitePermission names camera/microphone/location across processes. Shared labels live in the commons Android strings; FormatSize, DesktopWindows and AddToHomeScreen glyphs added to the Material Symbols subset font. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../src/androidMain/res/values/strings.xml | 92 ++++++ .../amethyst/commons/browser/BrowserChrome.kt | 261 ++++++++++++++++++ .../commons/browser/BrowserSitePermission.kt | 43 +++ .../commons/browser/BrowserChromeTest.kt | 175 ++++++++++++ .../font/material_symbols_outlined.ttf | Bin 493756 -> 498440 bytes .../commons/icons/symbols/MaterialSymbols.kt | 3 + 6 files changed, 574 insertions(+) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserSitePermission.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChromeTest.kt diff --git a/commons/src/androidMain/res/values/strings.xml b/commons/src/androidMain/res/values/strings.xml index 35d0cd1366..6e673d6fda 100644 --- a/commons/src/androidMain/res/values/strings.xml +++ b/commons/src/androidMain/res/values/strings.xml @@ -9,4 +9,96 @@ <string name="browser_file_chooser_title">Choose a file</string> <!-- Shown when the device has no app that can hand a file back to the page. --> <string name="browser_file_chooser_unavailable">No app available to pick a file</string> + <!-- Browser top pill (shared by the embedded tab and the full-screen browser) --> + <string name="browser_action_back">Back</string> + <string name="browser_action_forward">Forward</string> + <string name="browser_action_reload">Reload</string> + <string name="browser_action_stop">Stop loading</string> + <string name="browser_action_favorite_add">Add to favorites</string> + <string name="browser_action_favorite_remove">Remove from favorites</string> + <string name="browser_action_share">Share…</string> + <string name="browser_action_back_to_app">Back to %1$s</string> + <string name="browser_action_copy_link">Copy link</string> + <string name="browser_action_edit_address">Edit address</string> + <string name="browser_action_find_in_page">Find in page</string> + <string name="browser_action_text_size">Text size</string> + <string name="browser_action_text_size_value">%1$d%%</string> + <string name="browser_action_text_smaller">Smaller text</string> + <string name="browser_action_text_larger">Larger text</string> + <string name="browser_action_desktop_site">Desktop site</string> + <string name="browser_action_add_to_home">Add to Home screen</string> + <string name="browser_action_open_in_browser_app">Open in another browser</string> + <string name="browser_action_open_full_screen">Open full screen</string> + <string name="browser_action_tor_on">Loads over Tor</string> + <string name="browser_action_tor_off">Loads over the open web</string> + <string name="browser_action_access_info">What it can access</string> + <string name="browser_action_site_settings">Site settings & permissions</string> + <string name="browser_action_close">Close</string> + <string name="browser_section_privacy">Privacy</string> + <string name="browser_section_developer">Developer</string> + <string name="browser_security_tor">Onion-routed over Tor</string> + <string name="browser_security_https">Secure connection</string> + <string name="browser_security_http">Not secure</string> + <string name="browser_security_sandbox">Sandboxed app</string> + <string name="browser_link_copied">Link copied</string> + <string name="browser_no_other_browser">No other browser installed</string> + + <!-- Find in page --> + <string name="browser_find_hint">Find in page</string> + <string name="browser_find_count">%1$d/%2$d</string> + <string name="browser_find_previous">Previous match</string> + <string name="browser_find_next">Next match</string> + <string name="browser_find_close">Close find in page</string> + + <!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) --> + <string name="browser_js_dialog_title">%1$s says</string> + <string name="browser_js_dialog_title_generic">This page says</string> + <string name="browser_js_dialog_block">Block dialogs from this page</string> + <string name="browser_js_leave_title">Leave site?</string> + <string name="browser_js_leave_message">Changes you made may not be saved.</string> + <string name="browser_js_leave">Leave</string> + + <!-- Downloads and sharing --> + <string name="browser_download_started">Downloading %1$s…</string> + <string name="browser_download_saved">Saved %1$s to Downloads</string> + <string name="browser_download_failed">Couldn\'t download %1$s</string> + <string name="browser_share_chooser">Share</string> + + <!-- Page info --> + <string name="browser_page_info_title">Page info</string> + <string name="browser_page_info_https">Your connection to this site is encrypted.</string> + <string name="browser_page_info_http">Your connection to this site is not encrypted. Don\'t enter passwords or payment details.</string> + <string name="browser_page_info_tor">This site loads over Tor. The site can\'t see your IP address.</string> + <string name="browser_page_info_open_web">This site loads over the open web. The site can see your IP address.</string> + <string name="browser_page_info_certificate">Certificate issued to %1$s by %2$s, valid until %3$s.</string> + <string name="browser_page_info_permissions">Permissions</string> + <string name="browser_page_info_clear_data">Clear site data</string> + <string name="browser_site_data_cleared">Site data cleared</string> + + <!-- Site permissions (camera / microphone / location) --> + <string name="browser_permission_title">%1$s wants to</string> + <string name="browser_permission_camera">Use your camera</string> + <string name="browser_permission_microphone">Use your microphone</string> + <string name="browser_permission_location">Know your approximate location</string> + <string name="browser_permission_allow">Allow</string> + <string name="browser_permission_block">Block</string> + <string name="browser_permission_system_denied">Amethyst doesn\'t have Android\'s permission for this. Allow it in system settings.</string> + <string name="browser_permission_ask">Ask</string> + <string name="browser_permission_allowed">Allowed</string> + <string name="browser_permission_blocked">Blocked</string> + <string name="browser_permission_section">Site permissions</string> + + <!-- Long-press menu --> + <string name="browser_ctx_open_new_window">Open in new window</string> + <string name="browser_ctx_copy_link">Copy link address</string> + <string name="browser_ctx_share_link">Share link</string> + <string name="browser_ctx_download_image">Download image</string> + <string name="browser_ctx_copy_image_link">Copy image address</string> + + <!-- Home screen shortcuts --> + <string name="browser_home_shortcut_unsupported">Your launcher doesn\'t support shortcuts</string> + + <!-- Renderer crash --> + <string name="browser_renderer_gone">This page stopped working</string> + <string name="browser_renderer_gone_reload">Reload</string> </resources> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt new file mode 100644 index 0000000000..a5f8790c71 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChrome.kt @@ -0,0 +1,261 @@ +/* + * 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 + +/** + * The single description of a running web surface's controls — the top pull-down "pill" drawn over every + * embedded tab (Compose, in the main process) and every full-screen browser window (plain Views, in the + * keyless `:napplet` process). Both renderers ask this object *which* actions to show and in *what* order, + * so the two can no longer drift; they only decide how each action looks. + * + * Modelled on an installed Chrome PWA's app menu: a header naming the page and its origin (with its + * security state), a row of icon buttons (back · forward · reload/stop · star · share), then the menu rows, + * with the privacy rows and the developer console at the end. + */ +object BrowserChrome { + /** What is being shown: a live website, a verified NIP-5A nsite, or a sandboxed NIP-5D napplet. */ + enum class Surface { WEB, NSITE, NAPPLET } + + /** Where it is shown: an in-app tab over the bottom bar, or its own full-screen window/task. */ + enum class Presentation { EMBEDDED, FULL_SCREEN } + + /** The connection badge on the origin chip. */ + enum class Security { TOR, HTTPS, HTTP, SANDBOX } + + /** Every control the pill can offer. Renderers map each to an icon + label. */ + enum class Action { + // Icon row + BACK, + FORWARD, + RELOAD, + STOP, + FAVORITE, + SHARE, + + // Menu rows + BACK_TO_APP, + COPY_LINK, + EDIT_ADDRESS, + FIND_IN_PAGE, + TEXT_SIZE, + DESKTOP_SITE, + ADD_TO_HOME_SCREEN, + OPEN_IN_BROWSER_APP, + OPEN_FULL_SCREEN, + + // Privacy + TOR, + ACCESS_INFO, + SITE_SETTINGS, + + // Developer + CONSOLE, + } + + /** A group of menu rows; renderers draw a divider (and, for PRIVACY/DEVELOPER, a label) above each. */ + data class Section( + val kind: SectionKind, + val actions: List<Action>, + ) + + enum class SectionKind { PAGE, PRIVACY, DEVELOPER } + + /** Everything the menu layout depends on. Pure data, so the layout is testable off-device. */ + data class State( + val surface: Surface, + val presentation: Presentation, + /** The page on screen now. */ + val url: String, + /** The page this app/tab was opened with — its "scope", for the back-to-app action. */ + val startUrl: String, + val canGoBack: Boolean = false, + val canGoForward: Boolean = false, + val isLoading: Boolean = false, + /** Tor routing state, or null when this surface offers no Tor choice. */ + val torOn: Boolean? = null, + /** Whether the star is offered at all. */ + val canFavorite: Boolean = true, + /** Whether an editable permissions screen exists for this surface. */ + val hasSiteSettings: Boolean = true, + /** Whether a "what it can access" summary exists (sandboxed surfaces). */ + val hasAccessInfo: Boolean = false, + /** Whether the console can be shown. */ + val hasConsole: Boolean = true, + /** Whether this surface can search its page. */ + val hasFind: Boolean = true, + /** Whether this surface can resize its text. */ + val hasTextSize: Boolean = true, + ) { + val isSandbox: Boolean get() = surface != Surface.WEB + } + + /** + * The icon row, left to right. A live website gets Chrome's full row. Sandboxed apps are served from + * internal, verified content, so there is nothing meaningful to share or step through; they get reload + * and the star. + */ + fun iconRow(state: State): List<Action> = + buildList { + if (!state.isSandbox) { + add(Action.BACK) + add(Action.FORWARD) + } + add(if (state.isLoading) Action.STOP else Action.RELOAD) + if (state.canFavorite) add(Action.FAVORITE) + if (!state.isSandbox) add(Action.SHARE) + } + + /** Whether an icon-row action is currently usable (back/forward follow the page history). */ + fun isEnabled( + state: State, + action: Action, + ): Boolean = + when (action) { + Action.BACK -> state.canGoBack + Action.FORWARD -> state.canGoForward + else -> true + } + + /** The menu rows under the icon row, grouped. Empty groups are dropped. */ + fun sections(state: State): List<Section> { + val web = !state.isSandbox + val page = + buildList { + if (web && isOutOfScope(state.url, state.startUrl)) add(Action.BACK_TO_APP) + if (web) add(Action.COPY_LINK) + if (web) add(Action.EDIT_ADDRESS) + if (state.hasFind) add(Action.FIND_IN_PAGE) + if (state.hasTextSize) add(Action.TEXT_SIZE) + if (web) add(Action.DESKTOP_SITE) + if (web) add(Action.ADD_TO_HOME_SCREEN) + if (web) add(Action.OPEN_IN_BROWSER_APP) + if (state.presentation == Presentation.EMBEDDED) add(Action.OPEN_FULL_SCREEN) + } + val privacy = + buildList { + if (state.torOn != null) add(Action.TOR) + if (state.hasAccessInfo) add(Action.ACCESS_INFO) + if (state.hasSiteSettings) add(Action.SITE_SETTINGS) + } + val developer = if (state.hasConsole) listOf(Action.CONSOLE) else emptyList() + return listOf( + Section(SectionKind.PAGE, page), + Section(SectionKind.PRIVACY, privacy), + Section(SectionKind.DEVELOPER, developer), + ).filter { it.actions.isNotEmpty() } + } + + /** The badge for the origin chip. */ + fun security(state: State): Security = + when { + state.isSandbox -> Security.SANDBOX + state.torOn == true -> Security.TOR + state.url.startsWith("https://", ignoreCase = true) -> Security.HTTPS + else -> Security.HTTP + } + + /** + * True when the page on screen left the app's origin — the case where Chrome shows its out-of-scope + * bar. Blank pages and unparseable URLs are never "out of scope". + */ + fun isOutOfScope( + url: String, + startUrl: String, + ): Boolean { + val here = originOf(url) ?: return false + val home = originOf(startUrl) ?: return false + return !here.equals(home, ignoreCase = true) + } + + /** + * `scheme://host[:port]` of an http(s) [url], lowercased, or null for anything else (about:, data:, + * blank). The same shape the WebView reports as a page origin. + */ + fun originOf(url: String): String? { + val schemeEnd = url.indexOf("://") + if (schemeEnd <= 0) return null + val scheme = url.substring(0, schemeEnd).lowercase() + if (scheme != "http" && scheme != "https") return null + val host = OmniboxInput.hostOf(url)?.lowercase()?.takeIf { it.isNotEmpty() } ?: return null + val authority = + url + .substring(schemeEnd + 3) + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + .substringAfterLast('@') + val port = + authority + .substringAfterLast(']', authority) + .substringAfter(':', "") + .toIntOrNull() + ?.takeIf { !(scheme == "https" && it == 443) && !(scheme == "http" && it == 80) } + return "$scheme://$host" + (port?.let { ":$it" } ?: "") + } + + /** The host shown on the origin chip, or the raw URL when it has none. */ + fun displayHost(url: String): String = OmniboxInput.hostOf(url)?.takeIf { it.isNotEmpty() } ?: url + + /** Text-size steps offered by the TEXT_SIZE row, in percent (Chrome's accessibility range, coarser). */ + val TEXT_ZOOM_STEPS = listOf(75, 90, 100, 115, 130, 150, 175, 200) + + const val DEFAULT_TEXT_ZOOM = 100 + + /** The next larger (or smaller, with [larger] = false) step from [current], clamped to the range. */ + fun stepTextZoom( + current: Int, + larger: Boolean, + ): Int = + if (larger) { + TEXT_ZOOM_STEPS.firstOrNull { it > current } ?: TEXT_ZOOM_STEPS.last() + } else { + TEXT_ZOOM_STEPS.lastOrNull { it < current } ?: TEXT_ZOOM_STEPS.first() + } + + /** + * Parses a computed CSS colour (`rgb(r, g, b)` / `rgba(r, g, b, a)`, what `getComputedStyle` returns) + * into an opaque `0xFFRRGGBB`. Null for anything else, or a colour more than half transparent (a page + * that "clears" its theme colour that way should get the default bars back). + */ + fun parseCssRgb(css: String?): Int? { + val match = CSS_RGB.matchEntire(css?.trim() ?: return null) ?: return null + val (r, g, b) = match.destructured.let { (r, g, b, _) -> Triple(r.toInt(), g.toInt(), b.toInt()) } + if (r > 255 || g > 255 || b > 255) return null + val alpha = match.groupValues[4].takeIf { it.isNotEmpty() }?.toFloatOrNull() ?: 1f + if (alpha < 0.5f) return null + return (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + private val CSS_RGB = Regex("""rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*([0-9.]+)\s*)?\)""") + + /** + * The "desktop site" user agent for [mobileUserAgent]: Chrome's own trick — swap the Android platform + * token for a Linux desktop one and drop the `Mobile` and WebView (`; wv`) markers, so servers that sniff + * the UA send their desktop layout. Leaves the Chrome/WebKit version tokens intact. + */ + fun desktopUserAgent(mobileUserAgent: String): String = + mobileUserAgent + .replace(Regex("""\(Linux; Android[^)]*\)"""), "(X11; Linux x86_64)") + .replace("; wv)", ")") + .replace(" Mobile Safari/", " Safari/") + .replace(Regex("""\s+Mobile(?=\s|$)"""), "") + .replace(Regex("""\s+Version/\d+(\.\d+)*"""), "") +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserSitePermission.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserSitePermission.kt new file mode 100644 index 0000000000..59b6870e53 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserSitePermission.kt @@ -0,0 +1,43 @@ +/* + * 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 + +/** + * The powerful web features a site must ask for before using, as Chrome's site settings list them. Shared + * by the keyless `:napplet` browser (which receives the page's request) and the main process (which + * remembers the user's answer per origin), so both name them the same way over IPC. + */ +enum class BrowserSitePermission( + /** Stable wire/storage name. Never rename: it is persisted. */ + val key: String, +) { + CAMERA("camera"), + MICROPHONE("microphone"), + LOCATION("location"), + ; + + /** The user's remembered answer for one permission on one origin. [ASK] = never answered. */ + enum class Decision { ASK, ALLOW, BLOCK } + + companion object { + fun fromKey(key: String?): BrowserSitePermission? = entries.firstOrNull { it.key == key } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChromeTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChromeTest.kt new file mode 100644 index 0000000000..5bf3268972 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserChromeTest.kt @@ -0,0 +1,175 @@ +/* + * 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 + +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Presentation +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.SectionKind +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.State +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Surface +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class BrowserChromeTest { + private fun web( + presentation: Presentation = Presentation.FULL_SCREEN, + url: String = "https://example.com/a", + startUrl: String = "https://example.com/", + torOn: Boolean? = null, + isLoading: Boolean = false, + ) = State(Surface.WEB, presentation, url, startUrl, isLoading = isLoading, torOn = torOn) + + @Test + fun webIconRowMirrorsChrome() { + assertEquals(listOf(Action.BACK, Action.FORWARD, Action.RELOAD, Action.FAVORITE, Action.SHARE), BrowserChrome.iconRow(web())) + } + + @Test + fun reloadBecomesStopWhileLoading() { + assertEquals(Action.STOP, BrowserChrome.iconRow(web(isLoading = true))[2]) + } + + @Test + fun sandboxIconRowHasNoHistoryOrShare() { + val state = State(Surface.NAPPLET, Presentation.EMBEDDED, "https://x.napplet.local/", "https://x.napplet.local/") + assertEquals(listOf(Action.RELOAD, Action.FAVORITE), BrowserChrome.iconRow(state)) + } + + @Test + fun backAndForwardFollowHistory() { + val state = web().copy(canGoBack = true, canGoForward = false) + assertTrue(BrowserChrome.isEnabled(state, Action.BACK)) + assertFalse(BrowserChrome.isEnabled(state, Action.FORWARD)) + assertTrue(BrowserChrome.isEnabled(state, Action.SHARE)) + } + + @Test + fun fullScreenWebMenuOrder() { + val sections = BrowserChrome.sections(web(torOn = true)) + assertEquals(listOf(SectionKind.PAGE, SectionKind.PRIVACY, SectionKind.DEVELOPER), sections.map { it.kind }) + assertEquals( + listOf( + Action.COPY_LINK, + Action.EDIT_ADDRESS, + Action.FIND_IN_PAGE, + Action.TEXT_SIZE, + Action.DESKTOP_SITE, + Action.ADD_TO_HOME_SCREEN, + Action.OPEN_IN_BROWSER_APP, + ), + sections[0].actions, + ) + assertEquals(listOf(Action.TOR, Action.SITE_SETTINGS), sections[1].actions) + assertEquals(listOf(Action.CONSOLE), sections[2].actions) + } + + @Test + fun embeddedWebAddsOpenFullScreenLast() { + val page = BrowserChrome.sections(web(presentation = Presentation.EMBEDDED))[0].actions + assertEquals(Action.OPEN_FULL_SCREEN, page.last()) + } + + @Test + fun noTorRowWithoutTor() { + val privacy = BrowserChrome.sections(web(torOn = null)).first { it.kind == SectionKind.PRIVACY } + assertFalse(Action.TOR in privacy.actions) + } + + @Test + fun leavingTheAppOriginOffersBackToApp() { + val page = BrowserChrome.sections(web(url = "https://accounts.other.com/login"))[0].actions + assertEquals(Action.BACK_TO_APP, page.first()) + } + + @Test + fun sandboxMenuKeepsOnlyApplicableRows() { + val state = + State(Surface.NSITE, Presentation.FULL_SCREEN, "https://a.napplet.local/", "https://a.napplet.local/", torOn = false, hasAccessInfo = true) + val sections = BrowserChrome.sections(state) + assertEquals(listOf(Action.FIND_IN_PAGE, Action.TEXT_SIZE), sections[0].actions) + assertEquals(listOf(Action.TOR, Action.ACCESS_INFO, Action.SITE_SETTINGS), sections[1].actions) + } + + @Test + fun surfacesWithoutFindOrTextSizeDropThoseRows() { + val state = + State(Surface.NAPPLET, Presentation.EMBEDDED, "", "", hasFind = false, hasTextSize = false, hasAccessInfo = true) + assertEquals(listOf(Action.OPEN_FULL_SCREEN), BrowserChrome.sections(state)[0].actions) + } + + @Test + fun securityBadge() { + assertEquals(Security.HTTPS, BrowserChrome.security(web())) + assertEquals(Security.HTTP, BrowserChrome.security(web(url = "http://example.com"))) + assertEquals(Security.TOR, BrowserChrome.security(web(torOn = true))) + assertEquals(Security.SANDBOX, BrowserChrome.security(State(Surface.NAPPLET, Presentation.EMBEDDED, "x", "x"))) + } + + @Test + fun originOfNormalizes() { + assertEquals("https://example.com", BrowserChrome.originOf("https://Example.com/path?q=1")) + assertEquals("https://example.com", BrowserChrome.originOf("https://example.com:443/")) + assertEquals("http://example.com:8080", BrowserChrome.originOf("http://user@example.com:8080/x")) + assertNull(BrowserChrome.originOf("about:blank")) + assertNull(BrowserChrome.originOf("data:text/html,hi")) + } + + @Test + fun scopeIgnoresPathsAndBlankPages() { + assertFalse(BrowserChrome.isOutOfScope("https://example.com/deep/page", "https://example.com/")) + assertTrue(BrowserChrome.isOutOfScope("https://sub.example.com/", "https://example.com/")) + assertFalse(BrowserChrome.isOutOfScope("about:blank", "https://example.com/")) + } + + @Test + fun textZoomSteps() { + assertEquals(115, BrowserChrome.stepTextZoom(100, larger = true)) + assertEquals(90, BrowserChrome.stepTextZoom(100, larger = false)) + assertEquals(200, BrowserChrome.stepTextZoom(200, larger = true)) + assertEquals(75, BrowserChrome.stepTextZoom(75, larger = false)) + assertEquals(115, BrowserChrome.stepTextZoom(105, larger = true)) + } + + @Test + fun parsesComputedCssColors() { + assertEquals(0xFF0C2238.toInt(), BrowserChrome.parseCssRgb("rgb(12, 34, 56)")) + assertEquals(0xFFFFFFFF.toInt(), BrowserChrome.parseCssRgb("rgba(255, 255, 255, 0.9)")) + assertNull(BrowserChrome.parseCssRgb("rgba(0, 0, 0, 0)")) + assertNull(BrowserChrome.parseCssRgb("#ffffff")) + assertNull(BrowserChrome.parseCssRgb("")) + assertNull(BrowserChrome.parseCssRgb("rgb(300, 0, 0)")) + } + + @Test + fun desktopUserAgentDropsMobileMarkers() { + val mobile = + "Mozilla/5.0 (Linux; Android 14; Pixel 8 Build/AP1A; wv) AppleWebKit/537.36 (KHTML, like Gecko) " + + "Version/4.0 Chrome/120.0.6099.230 Mobile Safari/537.36" + assertEquals( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Safari/537.36", + BrowserChrome.desktopUserAgent(mobile), + ) + } +} diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index c9e24fb3ffd7cdce077e1a3530f878ce3ccb2856..3af0f83bd12753dca85367876b4f9743284cd835 100644 GIT binary patch delta 13438 zcmaKS3qVwL_W$|b*UT^j!@vwP3<wugL?cB7Q4~ddKSRwjE0t1H(=;<9*BW4YZMkOQ zHre)Yd*`<O+-#%%Tbn($T-P4kw*AUV39YOM5d;PphTrGh8E~xK{~hk!x%YfO=bZ2P zp2zoozr1!|#$7LG<dPsF2fh?aoUv$e*(3M-eh5+fULx`M%u6OO6pzzjqTtl~ax;9v zO=$a0#ny_=Wcz+UQD*_}%dg&0vFU%`T8UO_I+4k_8uWWwH@pD)ZJ;+^Q*i_162bLB zT+P?4zj<}iukS4)vR_Pe<nT3Uzqb4n8&TIL()K>EW>v+?v3D)s3=P1K#;m~uQ-djs zd?Wy%L2EW_sl4*3%j3}?NF)zhzwzpd@WwG85!v$jenUm&Ch@ckg+wLV>(^CmShaM1 z^&z4!b<k~_Hr}ws_W9eN5gpGXa<4N?AO-(@U)-#3mfI(^nI-ukaP;lJ?kzE{U)}!= zUu&m|apJxMw~9A~y`0LaGWt10hia$GZyzuz2_jQMLm8(N-)veZDJERRZrqq?ckLK; zs`u+~!;prt4U-zm8m2dtHymtuyWzctFHV?G*bdyUZWm0?G3b*UrpC}spy!y1gfBfS zD=S@}nU#^{(%&G;f+dJDPi3Ce)Aezgb2I<0Ka=@JW@ctmW=ZDg%x5$2&va!j$~cqp zyNr7>{TbJ0{wZU0#y+Bqk1`Htyo~GQjEx!V2E0CCJ<)&%aGyKi;q=b*>(Xn}Uu4ed zWAWUHYifE;`qK0XzVu7e7o}}W&n8OCPUG)^Y3vD<=18-9yV88#IbN?f&3nizymP%b zdM9`@Q(sQ4A@UTb!r-Y%o{v3S+>g3@7~^@^{itWU`+oeon(y6TxIb}!<G#zi$n&gc zk0;slNB2M7X`njXGd!z3m!%v{@%_tvt$Vk7w)=~eS5v-oFLM_Yr8KxbZkL-<(4Uko z?#z_;-B~F&r8K#dQzp5pT_M-f6mLqI^OUpMHO}>zYnJm~M{?r(j_VyCCTWR=U<aJd ze!^k5cP4u5@7j05W1&=HynTiJPEbzSyX<EBiiEckw&&SBcyO70asrHRQ>{0e)|>Yl zgUua?9^wuR`rTq@t;3pEyI&{^#fk_;T+!DA4K9hCF;B|HLOhF}=brN~G1d4kGrmj3 z>{>@$MvfS6+`_JXhZrPs&;15$kjSb1y-1Nn5)cF8gMCC?o1^NPd<W?*+`k(`BYF*Y zeDd*1<kJ8BeDv3|{ZEJ{iHTyqD5rIzP!x$WQA)dnUD$+G#L=U)mm26RI!5(kw3tK> z(Ml?$BD#v6q!y9Bl8(|T`ckO$H65pa(<k&9mC`@xeM+S~vQaO>r;Ub*t@Kx#NiWiV zYNu9FDYnsQA;cz8LnCQ|sG>@dBAjA1y+rBslbA(KVhz1V4q8CZ)4l`gwi&`#KTH`` zHf;5<Cx;!nAo+r67rd0G<t@$&<b8c%;)QE2eDcE1;RVAV9DZ_yJi<AmWW=HoJ4U=Z zqA@=ye@_0o{M!5j`CsL?jvO)a(vi1}{C-sSs2!u;9ZjQ$jrNWHOTqerU}0h5HH8lq zzEOB`O#YY+V;(P3i>@!az373WXNq1a`g_sQq91)lp|S3<YsWq~_QkRP9@{uh9+x(5 z)VKxXel_l?aUYCpEcO)7FRm)ySA1f;XZ+>kUmPErFlfU12~SOUf5PzzXG)Swrj;x# zSzfZGWM|16CCwAFCyt%Cc;e=XZ%u5SG;Y$iNsmo>sdPo@!P550X_FUC-Zpvv<j=}- z%jTDDFMGf2>=e(G@l$-|Q(m1)Q!A$Ko0dOq)3nE?eL5{X{i5l2O#g0%bH?>E9-Hyy zOxMf>Gq=xtdFH3Ha%NpN>$}+_XWu;g>Dgb+QRZaMSvu#gIY;N}bGOZXeeU=3#?QNT z-t+VRHt+j+z4LAJbLWqrf64r{^LNjGcK+M*KbrsTMbj_(ZMnI8UHSg<uNF8KELu># zP+zfd)56fAb&Ec}*mbe*;*S>(UA$oNYm3ibvfvWmB_}WSUi#3bKP=g{<m1a$U-seB zX-jWj`ufrzFJE=}t5--@jJV>qEB<!n<STby`Q5Ui%N|`8T)t}gzUAMo$X@Yu1y#(h zc(~%kRfATqIxyBgW6@BV6STZZo=I*R0zad{g`*1xX6F<-3dW4i8kOR5IJJQTU87yK zD<0K-qO`m@Ys82wsfxdw%fq@<)l*ejUOpmA?BYk^O<5z#%bUfn@FqSH?dRD4SJpr0 z`U`$sbx}p+1GisQBZeq+h;n6JelV(XDU^;;*p-svayd0q?TYcv+D(rp`ihEN=#p!+ zNqAUARx~LkC5e?~9k*;RZ(g%K7oGZu$=gjC$>qrzrto_7MEa;FpU*G1uW2sdP9(*M z*8j6uwwE|h;&~!Z{>&yaFJTbY$JPJ-d~VqA1oLpvln1{P+y20F5$S1JFHWIhzu zE5(}1y^^`IvL`4?D=WhX=|Ili-(Pef{^%|t7tyHNy<a?GT%Z49Qtj<uzJ$75{FO(J zlYS%Zl<t*wN%tLCSU=CK+gGl-;o2=5H!Z(u?R6_R-gJZWXZN)aof?wo+OYAewd+?c zuh_hK<4wzN+_Zf2+H2Nq@&3oB2VOh%o?^4E-ne-~#g^qatlhTifd8j`n%G=B{OowK zyLR2#|M9&=?;z>?-AG>_(x=po9GEDQkeE`0M|i~mp^I#!<so9IxIkPeMu?HfiJUXX ziSeQonRA+$A!doWoK3|-ak02uEE6llO0in35$nWyaUD|LX0b(V<rFBki(5sNs1dhC zicszDwgqC>fp6O!l4Xh5#ScyTi@|#(-(HfQe4eCd*ORnw8%Zw|leB*(Nq>Htq*u)( zy*;0#Ll2VlA=>@xBuSqwB<YJmB-NLbbmC)@PT^T&DM@WjBz3()QqOFX!uceNM3SZX zB%3ZKi~z|A4J13VNp?L<vU@7Y-mxU7r;zN+e3oSWU6Kb^kv!xe$wOZux!`e<$6iA6 z<fSA}(@386BFS?BUbKSbOPWZ&S|@qkR+6tn+s!#7-?*FPo6<?He46B2za_cmOOkJ2 zLh_E6NUj}D@@_B5zcu4*A^CxNk^}FO{NR3)_uNlD`JqaZe+Q;}q2UuJNq(}6<Y#Oo zKU+rfbK6MX2jPGCf#g4e*?tIqaR<qNP9nLENPcxL$*<x5ugxUCiT3YoA^AOE4!=P1 z2S-T$80ROON&f5ulD~MF<gZSVeC#O6$MO7Ybo}IeAHF^&`5W~1+hUTx+eh+`FkKUR zcLq#bQ%F8Lk>s{DB!^x?YQRT1M2clDDYh<B;y)&3Kt3r0pCu*tZc>J=BxS@YQbu8p zIc5zhWAS_PL{g?4A!Yg&QszHI%7Ow?7A2Ch7|$+2kyn=E$4U^de}$AAL!@lmMv5<w zl<H4Nx#JE}er+aYS2iiNVDv~8DSMwKWgpu9@enBoW|Q*r!=(HriImqN>`k0+H<NOB zH!1JmO3G*ZN%?9jDGeGaCqE<QyL3{%-$}}k7n5?j336J%taA-1Jui?de@UvPoK%~a z)Z{g!y6+}6Z8hE@wvn2VO=@O4srvn-X1`48z{g1)jBC#Gqz?U-)M2>hrQoa~^}>fq z9bG``SiFBsDkF8;U8K%$Aa%idQZK%O)TO6Lz4Ad)*B>LFy4gnRulAA(r%`u6;I1c0 zeF)4RYbN!Xlces0LI;kLTDOwaH!4YeYb>dU@%#fk|9lCl$8II{2M?*I!L)lJX<`Ry z_UWW0Cz9qFPMY@<(sBw&yP%A;QAbEC#2d|o7Sbl;*{q`zNSi;3v_;2AyL1U@D-_aJ zf_UvZ(l&lZ+6@5Qw2QQ=5NUTlK-vzpy>BLIzpW%~&(_-f@MbH~LCm*G`BVGH@L*%& z&vyrLQtk0@u1Kgg_x}FE1d7ugWYImiI;lXn&=g&xBHc{6q|zkaN`<-%nhU4>z^A=$ zs!@nN3MmETB0#1CGF?}x6p%c;(nKM3@2KJl?Pr*|z+4W@LSPmGGZ&ay=fu?Qwuu9# zEGLzq-7K`55~EIWOuIEs@wFMe7o!V{q0g1*0_(E`m{MR$qxzHrvlv|%N%p#}1B5$n zXaZ)64xCQfiuN^VzZ31ZqWvw<Wi8s<$c58h_wo?&8(Vw=WkW_9WMu2kKzV^kQ(gFJ zuiI20MynvFfn6GTa5@9~#~I`_pg$TX9??J^4)So2hl9MW)F2NB`JqzrXB(6m0OpRU z{fnSX0hAdBWd@TKR~a-HPJ3PDJn@8z_DNuu1m#CUd2XMJ_62A^49XXvy$9`+z<=r@ zG0$X4pcJ%Ef#`>p8lqD``hQEscN$1hR)KvL28mIJE5!A&Ql><Qp%_wlh>IX)dW@8n z*NEy^R!tlFStVR22C@QuQi^ZvCoR2El*E$uZto`@+%9g3kLl)OD8T+Q7YZza0#l&C zl5+|Kz-mI><9CZcm|{Rb*SZWKcAlth7&Ni&rTfKT_Rf45$c^#IhfyzzN@I^+Mk>uD zD<sQw0X!lf=F#?uAv|b(gTP+OLBoT_9ybbt*yH*J;gY9BXRJAk(Y_SzN1}ZR+K)v0 zl9=|De-N*#u{QftzhRdCNsNr`V+B~<09G7G6=1a-tY*isdg&Fh$^upO$oLywn-gu1 zL`7xj8V8<^uBD@EWp9b&rl`B-MBR1xVM9E->-@vw_p#%`aXc67IM${>JjZ@)&pn@s zP95wLAl?jiX1HKRr@<}(>?U@K4;7IRNC=C82&dt?y>3ic)Wri~jzM%;C5|>T5MG<~ zmjN(MG<XBYh8iS})9_HKNCC;MV<dHFVB&Ct<Ob7a!zEcmI$>)CpzbZYnRY{6AJpaE ztLVK77BvM@n-$K$88KOpWW>9Gxd)iLfVmW{YJk}TOka9?vQ&C19+&}8I3Ji%h~fY+ z&jPa-m_3m5ATSRDGXQc1K+e|rQfDGM$#Mon<OH@aH`GOv4ZN^Q8eR9{a_L3RE3s{M zuIg)3hc*`W+Y+!S0*exe9Y*IeaS_CFlnjU165WN<UU%|ZNsa@?9_^kDm>Zz#eNc51 zR9%SfZ32cJ$PSDh80S{W8V5C0bk7PoDn>A`%20zXHn&Pzknl6|zMcK#!#kzlGCHQj z1lI5A@5GKh(&ub%510>**a3OPf^pL5Dk!uLo#4_i1qyj!I}aq@|CIE(SvUjXKliiA zcv+fZ3jFKO(y(7VzutsLyI${CX#eZdXbU`{2vWF6EP|9$bc#*IQu;<!c~3ggSF{{a z&4vTRg>Duw#lRE;6I+a&A4mfk!{&7Y!{*JPNT_pa;33JN$dL4zcjYJ2NG_<cm>ghY zgDMA@qL?9h>vO5lkOQkZVH1|aDT}SP1ae{vsvTCd!)k--rEAU6fk=$>C-Cs8eha>K zN=jvx8d%1Itbs8P1gpVWuo$Y(L3g>dXke*<rF2@F%$1E*=ed9#F<0JZuw=*C)Fxf? z|0kDdfyt6Q4B08h$B~I5CBH8@W}^3)UZah@Znq>aVrw}dG8#9;)|v-3xi)2CRM=Xq zCXXanOs84?6DMDm(KnKLz;HR?X+<6|Jo*ugy{;)<9?5#gv=8Jt<uqZho9UE$6^Ks& zD^3pyF#I5}Dgi6bfP=tl8dwd2x+aKEfcPz`@&*oL#;{Z9uu@Zo%=t_QIU!TNS{<A~ ziD1RPQUF#w_sRgP31F20Rw-bWh_(+5lCxxpH&KL@34_E5cp)&0ftjnTm;gmel^Gbk zHP@XSBLB(+@iCad`VoeBChr&_-^rb2?KrwI1q|q83~~}AmX47xW4B;iBt*z4fRQL* z+i}Wg+vOYMW6Tii-kl)N;skDimTV*o5=Q!LgQW!$C(M?Q^ANJcRFK(*T&Sm#1}8S- zsv;;OX*nTk!<pssViU^5qnFB=u-hQ8dFxVn8fRV390S3|16#1e_Dw|DVVTWHctR4` zaONmjCI7Dp7%wo{z<8sWb+F%Rh~~Jt0~ntCd4cf)Gj@agZ%$xif#KmBi{5xEjoyp} zxwujuZ3mgBS6LwQ6l>d^26>{6+1;J;3`X-rfpx%)EzoqIK^_Kl!u|69T75YfoE*Hs z<zTu#M~|aRbqU=N6sM=)bi--Sb4J7AdB}M#lmlz^r3VBE44w!~60Y&M+L9bX5_04t zNvcUQnIu^nE}5z(OI1=;jl&if=W{ya+8Z0&+t0Sg@!>FQS}54m6bxzRq);#v42B#h zzwN58@6vQ#^QLQEd{o6tQaX9PlrGV?>6Bhg>5fFRIWf^3Y;Fz)n}cRP91e>`ocaFy zGs5CX6=%emGs2q+m0}McZu)gga5lF2<+etr)o-yn8(W->d{ARe<e7|TZPg|`a5g%f zjpQxBp%3BjA$tAiM{s;z&)<4|qhD^U*PVWH)>6T3*4D;8a%1alrUEpnZ$z88#(G~P zBaAy0k8tBp^^kPFl0wLuVLi{8Gq&I{96{Szb2bk1S^HTVAD(0Q$|-I&W#KTj7U$r$ zxV5;owK!sN(TH9cwih;AjjE6*MKaZhW?!??%SV)!n_ENA2q-Q)xf7zX-v5^<>C~wj zM^|@udwaJU7pJP4+RcX})YBs=7K<YFggPWeQWWVIHjXiIYC^nhNyK51dpeqN_&R!8 zTY6+Zs$9?`*g{>cIJ&4M*xO6-E!0v?EgpCOu*s3p>l-sSRO)KS(WSBbX#I-0p;(WQ z6xCu;6-o5;tLJbeDo&>oYHSQOpAPvHKC0q-drwnyGo9|G#M5-TnodjYEeX{kp{3f~ zk`ULDzz0Ph;EwQVNoZ+Fa3o@^qAngCqeMmNu~>TKU{DUp(4nU%(bCh?!uKtFZISU8 z8HxJ(8XwHv-0ruuHxJ5i;>Z}(Z}6TT9*g!azoo0a;n-(5jy3cXySi$s;*k%$2wyM4 z*NgDYNBE9F_!c63HH57f*{=|xT8L1^+67K44c29wH6|>5k`+<dAB5J}@UzASUR7L7 z$XR29%^DkK<hI*guAka@l-hrC+4yR6{q$37tG~+dK_j}1NUFj8Z9a1Uw<z-eB66s} zPwq1~eh~zAyWQ{R5cow9*lfrAf~bmmt+C!~jry(6a9naHB*g#BGvS!6EiHbEuXg;x zFMF#g9;$SKW7PY))jt1IHO=qt2%U4kJ_qaxg*yD!SnsQf>z#eh@2u@-;D+&MdV9~r zTcW<E7`|qWi}A9!82^fk@hl2_`G`CO={|Pqkoc&P?s@i-|ET=?z8QUVCK5QG{43R$ zN^9Uy$tXr9On(&=F``T0R@cL=Ho>h_)EE_SswyIY=j2?JFek^v&q5pEYS+NkR_ZEU zrdz?I|J4TV644F?2$ex4Tg3k-i(O(@jW-qRviw0Fo&|4_qlE&ul1R0>T-MIhr<)oZ zn@*qZ?6mR`QoAiSB{fnw&a`$Q%Hwbo*WMl{TP@vcC_9S+jsc1*aPWUr%_ke2vbs-y z{q@(~vVFjS0hv0EO#HCRicwEOt??aS|BzK*-^67`$4+a4Su<-C?rN&9?=*FqOr1^N zgpz_H&5H$OR+9bNI9Odd(GcO~0>l-lC5WSiD-o)Y&yH8mcqf^w<MEUlTiec_Jn>B< znf<CW9`B^~^fVZN4}2Rx&a(ijkJ{Fl6b^^0?f6PcGHOyAX5QIym4s4N<8*d~O(six zUtN;2Fdb>b93$7LOlA7XFF*R|Bd$wFj^M|aC-svpob*~w>Pdb|y4T$1^?v(pM#iL6 zKVmUATFE}EKl*j!nI0+Lspkfczo2^U)n@Oc%*^k;OG|6_4;n~;H~%WRV=X)|zVkSa z&Um8;adpbhHi}WCLRov#TTZnNjICXccvUhf-5ngx);4Qfn>BnUlw)>PiKeESDiIfl zujXca<>VM;Damh1>Q{cp8@E*n6s7dE%P5-A;6jESV?8LXJBX5n3S~igz_+3pbxR7% zp{{URw3;F7iQe9DH)>nAs&@Bwhvcv{Fq;C$Z&Te5xMVfzMA?zzC8JEWV0`(&k6>@u zC}5JQOHH(9_ZPR0o?vIFC)7iNVM>qE(}NWPsYAhJja4?Z?$a5!7<?0Zli^G{>)o%o z&(K^}6xmQeqP@=ABkic?)bED+F<sCzDe73bux$+c#F{5lwk9Vh%i;E>Cd3$`tf{Fz z9A@`WBy&(pPRMjRZA!=o(%DdZcc?og6dRXMB@|Mu4yjG;9grRs!px26=$q>q9T5qZ z8h1h~3ny;T+G-S2a|@k56|R;nD6y`N7DaS-btE9|8D+Ki`_t57?#m278Pl}$;v%)j zY(|mo;85!b%8^RjBRUmLPK{RIUJCb0J-EUAqDLsABivnuVTM1QIKlqZ+~K!$H0vIo zk9c&$pFriB+wHe>qvn2&<5&X|d?FO8sj^W4-n2|K9R+t93hpF|!*2<vFsA?02=A+e zMM~tjNhr3w7UfJnm)_VuFaz>h^7(l!lF^dS&uEccrW<+7_jU6xQdg+8H9o!rdCRXG ziOa!R?fle*q!mg^3I>JWYqoMXk)GPvY1wM*z->Xh*<3Aqkf8jM*(}$%n9_4DOUY(4 zvXsYH4XPdU!Lxo7vQZo|QP^+h@3^Ekr;|Bk%l>NlEYF>-He@6cMov0gEh8U!swJnh z&96sO(l0X7xeTQL=M3a4C^(mV^#7J<tUW#H=`F}Ie*M3t86=ocPLA2^@~60do;D&w z<NA5R;9&E0g<U_-6_G5VM^b|M7dZiMJ6&BW5`sUoKO;1spAwJ^q>c_nxh)dx?Va6< zhCt^`#i`1oDN-1zB^2uNh5d;3&gd+|#1XF=8LP9s9eRXhIUJV!x)IFDaglKTiR1Yv zBcL&O!Jvj{_UlGCw=oIv+`$1&Mnof)rM5Oz?e!yI{U&olYbS@S-^?-F>1#E&v{cKd z5waL%#B9)S<)EcrOn?x#)iOf%bhXsdV)plWJOja@&*RUJ*GQx$pC74_7^Nzng-pXN zWEW!X5zIn%=z)8llRcQ*6l2a&j91xWyvpu>&Y0Wqrpiap$yf7>@L-U2kkdgfebpcj z26@G+a;6nzp0OlFW-Ngn?-+|J&%PtOd4(dDbmSifk{1{o{~=fN!c{DJ+b8|xhdz-f z^9E3v?!ev$wpXy6P>HF~ZcK%4#Z>4nJ+k_`1&Lsm?n08V*O|YP=kS_k-=YVWAOb6X zG+0_duKH1)!^<o@i;J#_phFfcC1{wuo9P0u9E#bMht4e*2AkwFj2Q|{4%E01YGeb` zx17UUGrXLW3pIv9ja<k{X_fEfj>ljE`??G@n0&ZPZsnyJCrrjG98OsDuuyp6$Vv9P zAB56oENg)rhbOt{^hjjC;m~j-tfeFSjbsf8%Q9$hHY?95V3&&aHn2+tyT95DcBvpA zvnl?cldp61lXp6lzwszsjQ(8#^hlsrcn$Jm=vw7fu4jw#F2f{{3t`c4u0htpJY%Th z=N+`j%Dk~)8F-=4AoJ?{2ZhRM?rApA*%aAeJ3PT4X9NAi1m!VawVnX-1dtQa+ZWN> zTQDB)ptn!Jw(r5VD`DGG^tKe3muD#VWMMC$Z`*73T7w;Ld#zim?6JX!zk+1*Rz<xC zYlB|s%)4p4byy6xyyVGiTUlVs`vZv>GG5ah^Ec(@%mgk%NtmQ#Yn#&b!1ZHAwl7_m z%_c4^e3<08#@0k<h)kiMLQb_^b(+);J?T_^Pn#)8PpS`DL-mo?j4&m7nuCdn!JsKv zAJldHZyUu`i}tu`YByofjgRSq5l226Rq)BDrXfd$6y*IgufQb2+4}Kgy<wA-EgkbU zh=yaP-tIV4uw5dP6eqQZ)o{BhtTuPp5+%*l(cF<JbxVo=p-qq0*k*SkS++H{Ii*g? zNuAZg)LBg?6W;xekJ*IRAFLcnQk5n!r?#NBSSUzlGFyyX5jb#C$-x*6(H%HFIGwZ| z#=H^6^ud_3;E}vHbR~>A1jZZ!V`|?h$yn8^^EN6^uy^Rd@}e%UkRA?3oCnQ*2r8Gc zXL8~x$MEOE^~!q;av5Qnxkq`<6_xCcO6E<`Y{UXDA#>!7hh$!;9~_lDJz2e117;v3 zW&$%1+Rg%oSG##}pBGX`Q)G*CATR@=hU8WKyjhtHOfodwnWb_lCWG`smih-~R|E~& zf3Zer>@?j9b`@Y(h0d>s3KdYHkX&eIue&2xUC!&5V}Kb0%ot!UhYEeKaI2xhT3|K- zGX|J3z$A@PH`-!UxVx-hg?(k}opO8vjR(8J2)n?<=>~Z`kjtj4ce1n%AnyeE7D%gs zwA-QK?a**1G`tKQz6a8FK-zCu+QsVUE-bn>u2p%_6#-C}xK1tP1yo*i<t0~MKrN%l z21nm+`(&^&b^w9kMc2*Ot0u;9YAD5%EMTqzW+lewD#HFMI(juQ9KE~=$?Kv|SE?3X zHeCqJL|_(T$X}~5db1GZFKg5i?#)z?hl6|(Sl*1@@IL5v^yXHuyc@RTb=(<XISZKS zyVO?RvSkO&(eZx{P=S+k0aRFk_OsA_0ov#2E}Ztds~%DRzzL66v3cW?SFycMtGo*^ z=4o{x_lPSJzsB?1H}B~7t;zBRIqv}ThC<)Q;vIifzu;GY&PNvj!|TM?9WZ*tIcets zwZa1OERbhG+H8<RuNmaoAg8~s?y*B6?}@TR?4rU)&q?HkYpyPfA<>Ku^G0dI5%oko zoF%$P9r)m=!H#$Ln~tg%a0j@u@CrSrm@VkxRt(8T9sdLYwO4}O2EybS?6}-aZ%|DR z*e@NJ3_Mv1@rz+U4ws7{eo0h(I@)KT{nl31#dUcg+Vi$L5BmmSu7eC-qTqkDxB-|O zpfV3Tmx))p)hSlU;1Y%M1OsCA4DEUEtPna4A}ftX`&6{&nl;FzZPZ}3IJ8$G17$F< zH(ukg<cfDNUYjCC$#GF~Vq!lzKT$iuWFBD)$UMS&vO(s3iqd55D<-Gw4xAoz!WkHr zVvy5yd)@354Q~<&fw`&LKcTV-txUQTYchC&g33Hr>>-KhVgk&_yFn(vrhBzpoY0R4 z{m^i2B&UtVP+%DpScWby1O9U0mjQn{@Qcyq1;8&umzM#PQ>Z<|g?1P)!+;qKt&UDH zwBo?&nxfe)V0i_|i$J~tEKlhc`a#!lHPer}N^gSYez05$mRA5XW}Y_Qi8Q>qLaVjl zs$HwiFhRtksEA<@v2?Q`VhWmX+pPVLgYRaL(;)&AJ){r|CR!TZ3$1rS>&+0c6Cx@h zVjVDLRaz!b4yORa{~|LQEU&xQV96<b=e^o!4)+T|&I36Q<PY{3<O@M=+M|7EgT#_^ zXv6z&f<*qehwIU)n<0_Y>v%}ygeN_tJ;QSb#teu^G*-y`z$^pi7GUlMW<w0-(?4lH z@*K4wiYZ_<UN_VzU^QOX1n=2TijpUQ9D3g%PXamp1FggXi3Pd?(eJ@D$VtD0UFsn5 zF-UwH68Gp<{FXsSp3_kTj%y>WF!?J#Xw%q{D}Y@BY(RGg;?5YX*MRk~GuovNgfj12 zAzA}>OD6Vl-k#bgnSRHi%*B!Yp%A0-9Hc%6sZT)a^N@M~{d^dsQ4HG@1G72KG?_ax z7Z^^T1Hkg2*I>!Xrrv83JQ^7wdqGCD2bu;MWDe|v!KMIo4m_Q2;(yr~ibo&hoBqvp zsc*9Ne2LMTef&s?sfW`*o}s!iv_|3K|F^1vemp(@HT1g^R>_8bdBC(!H(fgvR=H}E JX{kpd`ahOEt{(sZ delta 8602 zcma)B3s_Xu+WyvFd+!+-m;r^EVHk!DiXtL{a+90f1VIo&Fcnkrh6htI&D2yhEltf# ztjSB}?Kdlrc_gLK(=jtsk9n-)EiY-<)jXzMOiyP2yEX%Hdj9ACe|r{tX20wGzIT1= zTi;r1TC&%*|2bDAF(UHEADR5}2UTRRk9+wv(T^Gt+t7b-RvCMVB8YxE@LZ&;?x*YM zd#Gwg)g$Ed%`T$bXjD38QdR9$>+*UcC4k7Fjt9Q%he<n$eg;g}tE;Bf;^_ygc07&M z56>E}{MqLYktLbv*r{stukPP{H2PmBb>+zk<Eq9cPnq}z6gbg8aROc#nhjx8N30w# zI!~B1eP*S-(t!>z!W|D!8B<mMW#}d%9}u`>Qq{~_wuwVFn~DC~<f=*IhLtZkL3G{+ zxKHhrY17wc%u6M@WGAxT*FzxP@jExy@%Bhd+D}G_9{`WOJn;A1`t$6V81bynWqsK4 zhI#B=W|&F(fAs_Tdkr0B9~MR=p$kU~z+Vy60Fu@3p<xxi7-&^}hLUS<?%JH(+_yQq zIk!2lxwQE}^Lx!7G+(%CY*??%VfN-OI-2zd)X>n&ki_b2Va_m{);}yL%um}#6lR1E z3jI3tbIqwGgcgM!)i#Ij3k?nJ5Skv^J9KO4iqOE&64x!)I@hw$d9I0}J6yeqT*qC9 zUAtZHxemCpTs5w7A%6{dSlDrO3VG3a*E!i)?-Y|tPE1XR1<xSoTxW$d$yrwC92h*q z8A2573U=XdXmG6P80;Hta@-D{?HJ&2ID#GTIhdorqt=n+a0KlRnoDF)3F=A|<ZJ)X zUTfQ6`&Dr4ZrcWXwrv$YjTGN)7i_0&U)Yw|2HCgRSKBT2?Y0kX!9aa%`SvP%x%IsD zpSHSc+e%xZ?Tqzp>sPj+wnWU_r?vo_pY<2(ZhW3@b6AhqoYra9Z)|33YT$yvJAuQj zf!6eZuLEudCIqex%nf+fzoXxW{*(NVbzpvDgkygjlJKg(x8<&%-E!En0$KVDnwMpi zWigP?Ew?R3%W(5S^BhY+6ke2D(#%arGcrvzJZxO2PqVP&`NtMvx|gu!^<k!H86QX0 zZ(;#yY#=Lb?IE75kQL#tr?p4$Hbd``rvFW0x%FY*t}ZN6|JvpEuFS>4TfZ?(2EJ?X zWtEr@7$Lq$yS9JVgqr$Y%uv@v`|<Wb8zMgJ^)&GrVf>GO+dSVkw|m<cOQ3)apc)p- zda-ntN-LQ+ZJ>?xDSblc=^~3^DYTZxQUWE>XxdCSn2pZR=X7oqV|0lw&_{HVPEjU( zKu0Nvq7lts=pNmoC^nUvs6Xwb9rQhY$ELAIsW<(~CbGE{M``SFn#uf`g;mima?&>} zm#(of^d9-s1GJqWwjtIhpVfU9r9|aKRY#ra9@+h|?r%r)=w8uFqYw88?lHJWQ;ZrD z5tA4*GUm~koiP`BntR6goYZr9&pka)_xvq3F!q7iS+Ot2nc{NecE)|(E4)`xuT8x! z^<EM0h<`Bt>G=KepCzaXWeLwF97^n(SfBVx;#-MF6VE4pn|LqDH_4UMCuvzyQ(e-j zq@R<`$q~uv$>qt@l3z;xAo*q=bDxwxwS6}CIi8}Vq@>JEIhE>4%}ZU8`a$Y<X)LW{ zT0&ZN+Vr#qX)mPhO8YFmLwaudu=K~$KS;lmk&#iCu{GoEzT^77-}jeHEptd_UFM$5 zOIba#24yYC`Z()uc1U(+c189(IZDp>oHugo5^^8S-I{x$AMaP*Z&|<Vc|mzI^0wxE zmhZ?PoWCG{U;c&u-TRN|f4v~FU|zxN1<i$Cg`EpW7CuvWc|gQ~x&a3V+$hQ{T3Ga# zqGLrjisfSe;vU7B#g)aAi`NwYx%f!&>Edq(4j8z$#J6Nh$)1wtQfuju(x)DXc;L}8 zRyL*V>>$UGL5l{REsrT5T>ftP-HO2#iz>bu96I>r!QVYt_u$zf6Nj7{S~zsx(1Sy7 z4yzgVPNktTzVhkHW5e@?uNi**p_qrZJoM{`nh|e|xIVJ$$k#_Hqe@0?8ufKmw{bO? z-EvF*p!%?}hwD3yJ=k!0oYtkG&+@NJ8aiEE!FVF|sekj*7X5kPQs4SDpT3DoJ@B%f zH`F#48|#~|g*PO8dr)rRH(yiP<ocbrQ`xHeYq#I1dyn4NGx6{AG5yR8%*1?=WB#lo z3uJZ{%tBZw3um1fRw0XG(MWHR`0*@}r6T<^SvJdM`C>`1VphTiv!Se#jbv493>(L) z*`JWTlh_nCRb(@JjLl+?v$+f#nZBg(x88ZDm@R9lx#KT+<+5c0F!(eq`uDR^-Byye z?j~vbBP6{xpQJYrlhou#(z|w&-XBiV(PJbXUr*9UM@TwdNYZ({yYv=GUyUc}dMrsd z4wCfUc9OndLedZ5-Z@UvFSD^X4I?hCC2qhzW!z8Pw2Qd!3gZ46@eUcpt#^nAB@?eh zedk>q#Jim!-s@xHiSvl3MiEa3Irkj#{^N<4zev3D2JunoJ7xp%Y9HbgRuivr5w9IV ze0n+Y87GO)izdEk8}TPs5r1kv@x?X7mkc5PbO!OIk;IpIqunI_3~a7~z~_fk9e?2w z;x8^DzNU%z+V6?4hx3<46Mq@THo@^$nfSJ)#JA5R{@NkpJMn$j1me5Vf6sN|Z-KMF z3-JTF#1Empe~tLDJmMe55dX(0;wQ_9e}wn{#K=z{CVr+64TC+qoA@W;b;K_&A$}R7 zy9%S9uOR-#UgH0fh~EV9CoA#0h{Uh`iQm^q_MJy^$J->^&ynodO|q6qa`<SHyX+;o z8$QP^Cpq3ha^gXfGrlG{>j{!`Mv$C`cll<LOYtlRHd-Zld?Cp-bw@~^GMnT_!bzTS zisV^?NuFOz@<JGT<^;(rZ<D+Zoi|uX-tr>J+pdxP`Y4ij!r5-LH#10XY9{%w$4NeN zpX3i$k^FHv$)~%Je71(<PcD*tVKd2B(nxNGt#9^{{I7c?{}@H`-3=ty-J3uP-%E<& z94V$ZN%1*Cin*2)zg)B{q;!<gZjxff)B0~x9Pwy)I>(}&Bqih;DV?4mC2}AsJy(*F zaE6q=v7}@*k&^owDFYHoDgKg_hoVUtJ&crzH%OTlMamrboBuu4DNA8&Wd<oP_9A8N z22!?0lJd%KQr<XB%I>E~*@yS<;Qi5Uq?|ZT%K7o6T!GPVH<EJeJSo4eCY6sM)i{S# zQ#h&CCrAxmNopqtsS)px+9Q+HIJ`@VBsF6`skzal=5Hglw2ah>OuVdoi_}qFNF578 z^{1pxEhP1^&q<ww&I?{3^~uAeK7Hu<BkU1VCN?zE+W~h!#v*(Vw7tM<_hSs@!{Xk^ z*d|{Kd29aUrP=WebPtqRFwV{1nz?bZ#NJgru*CzX8Qb9DMz%%$1MFjIgQG31!N3CD zSFEkC_S)EIMi46Ct^)QRg1Z}`IvPjjMzh9j-d+~$zy4@}?wBYY@&Pn3iaq1g!$RR; z27?){1yU4wp{Zy_GzHCs#(@Q(nH!BsthWMAFw6y$9W9Wu!N~?E8=PzpCmWm&I2;GV z&xADgk_t`?I5FVFfb)drMXNQH7HdX46?$4T(UTen7Jz1M?3Kgz_&|0DwE1I1H%oNc z{*d5T%C;zgI>1~9jA(y_4s`(QVg)NUbhD5ZkQH7s;iVMH^5CTu%6eclrBD`t|E%a= z_7M9@MSqVeN<#m1sER@V6sYP6Rehk!t_7fvxpDMZHnqL$i+^f!eeX|fL3>k0FjWRq z!gU!;<-t@*J5$?gStlX2)pZH_SG4P&gZ^c|_jjA0XBmyLGudzLoy-Pl8Auc1WCffo z1Zg!$4v*d;b!@J0yHI_)yiM=@<?Jg%JLtyhHVCf9h!l)P6m6KVSOj_AIvtKh5H_r1 zUB%S2#{a}CZOj|5u)FQ!bMwz_kl#-Bw(`G>Hw)5}ejjh+_YEwry}syuZKjI$v2k7y zpta1hyXlY)Iic<RA$Hl|NwqHRQtdeeX&HjF7C~AEPjld@HPya6j^)~JJS)$)dD?ZJ z-PK^u3{OUwGa{!d5c9#L;%TG@Nx^3mo*aDw(9DfzeqsNRndSLyQYWnV7|p!rgw*H@ zBgS?{YG7mvj7-McS{SK?ky;or!iW(@cAKSrA&8b|)lww-cgGkafp$m#aEt+a9`FD( zbK?uqQj(1RHVspz1p@Oz@~e0n@l+tggoNh60?^Ejw__!)qKy1IQ4)1YfeA;F^pgp* zF3MI%t)*-^b97jgt+X8JzkC6QU=*={dtp)DDAnN*u+0xhcl|I(!47F*qi%Rn8boAj zMhIxBCA}fS8zNxRoi{=1-B>+Z+ARvo|3ysyo!AJXmq^fbI7@=FbZ8kqS=W*dsdFYv z+#9gPGZG)b%V+Aa1@MlUlF1t$6^z6Lwt`9Bi1@CB)Q=%`38bcICX^@+tR*;~KOsF} z{sU(QI5WVR3r-C<BfuHajx+i>>3QL&-9A9Q5R$Rrlz}6vLN78=LA#N4UN3zjCczGE z5iMbKdvDjJ+Y#lc?b0X4ut21N7gfT{P?#ADGX-$nK0tfllJX7iiEl|!|G&;t4d|DL z@zlcTXc(R48Bb-q@$^3+_4Y!}%sHy-5lONBsC2b;v-0=IU4F6+-f>duBw#TS0f5Cs zTs+e@5%<nWabhQI52svggNI#|5_C3V5rBvY_WI@)7_r#jEKM|e!t2-KThF*63_%p# zJPe^5!rMMB<=;v{!lVk5zTl`7=$?H`H>twp+FMdqt6s4+Ti|id{Wd}C?@Q^byWf2& z$|L%J?o%yHv`c5VpTdKgxiMPdgT!$C5e?50aWD7gV*QD2ZHG7iOY6CyHI_AgI`b(% z9w*AaFzSJ*Gg6=cLrua^U8LZ<2~8|6bK?~&|4oJ`Gy03AY{odQ2kV~9fPI{Nl2~#A z7Sp1kV3c9<MMhP^Tr$j!(M))9^1@oxz}XhY$0&-0{9wx5RX62_*D+mr7>6eVwU{y> zpu?kd*g#l`QT%ZOJhg)<2cELwsR*9ZFh>u-QyS9D2v363xffq7P76YvSe`gO>HG3f z&tNOSiKLdz!T?SO$T*kGhqgxD+`@5Z^wnjEc#P=F3q%F>f=Uq&FA79jc+o(ZO!vr0 zgp5eY5Cistj8jGYvX~AbBM2N3!vke)F}za7D-1a6B@W}En840p3>(J#i3%y!ZYMD8 zh{cX-ogvol@oIk70N4RI9IylMt|>a~0DN)^KP{?7GGH+&$<VkO8lQy5)zByoNjos? z#n6~c0chsNk7x7V7I4IICkz~M<PqCb4LG90)Pge(;~D~v730d#aIjj+^MxO=dj~=z zPEhFA;-?mzW#G&MrxqM>fQy2kZL9fNle^zG?t=byN$9TK))wQH+xVtGrtg2kk1Osq zO<b&<6!=bo?-YdKWRq?}993^N@i7*_5r8$oPQas%>2L(#`Nw#usEwj-cfe>-x7|BV z>kH(_X>Jp-Z4X_)s58aZ=5vWJ5J%+p@Y*lh;HEElmZ&7znm<LsaEum6GZ7MTKAne< zhz;RIgk(BGQh+e#Y39br8+?EnCR?*qq{sYwx=Am<8}IP}941AuT9a7hT^vkCz+`ur z>;{u|YTFL?*yUS-(hZa@P|*V_!a<1#MJ)1oP|`pVN!kr6BH<=cmKTe0x8t~vnPgE? z1bodT{~(qH3XGnS0f^YO4mympN~3p2`6qEC^9JmVG!Tc3IBGfl#vxc5neZI~VZa;x zBZK5uMOg_#e;+6eg1JvabaO#~?}x|>{|K)LZ-aM+%kPUxD98AQgB=I<{O&qj0j(Rm z%Tq-_#i6b*-~<HJm7>EMjAx|C3w;2$9B^7TgA;i=EY34G^5pNtNW;MnC;ckpzE-Tm z;b40YlwTG}p9VM$updUd8>5|z*d9V`w;;A3Ahu%>+f0l$6Pz<c<Yi%AZFdQG&1~JA zxHc@EEwAxGj30;R$i=c!f*?AeS*#^-<w${9u?6M8Y#7Xn9o`R9CQcuN&&abv-FI)x zVj*<Z{L$=af$rF!b^Be>r}SsJBZ^Do*!%JpaY572P3#z=P=~|5*to)BKhk4gWRV8@ zqEN@1l%Zl4#2PC_&q#!Mtwj+jkxk~tCX4cFpvSY#<GD6MhlS^r5z4b7j-B8!6mTa9 zi;mXePJoM|m4)Kc(h+b+h{=VRz8GvS#1ue`xGfIAU^_xgNA$nbTX|iWOM)0NEhuX4 zT`9V`B#1egqKp(3GZAni;6%V_{dG7I@bLc16rXn9!r;w`p@+enlN1U<Xq^~(tG6G^ zmBnHOrNUf7i@CPaRs&8Q6gGe}2ntg%%c=0OeTcGHNSFl93UKBM3FCDM&j<<Ql}`di znV+MGG9QRn!{#UnV$F#%FN(aV<Lejd3{mFyFH{Ty7I~ZrI1KQ~r8+E1>CL5zm&n92 zz!`wcU{jP|QL<-2;}mFI0F9HNu>=~+!0Gb5k}fpnf+OzFB`{gLSvOe#c;#m02XP-0 z$<#%|QTGiU>Z0LH@rLrc5fKy{pSZt>jZYl&s^Co|sMt%z*)a~@#HKg@Eu~Xy9Tf)+ zQDS%fO*bd%=*hn+7sSCq>^I#3x9&H>+z6Pfgt=0ftAM!#q<~l}=iXPUyuc{{rvMTP zA>nmMcnO@%;A{rR4UQX}0t~niobmrq)`*Qj+?a$6T)W&pmvtH9auj`8x$28ic`l$d z0NRU4#nXtVg1K*5a$@@uxAx?JDc!}WL>Uy@zbJ#XKkDX08C?0JV(>@&oPb?`HY%!E zEiOO@6g5!n44nXpn=mF5GvkG8n@ZIXKLg^YLHsm`*I-vvD#@(om|#v+Nl_sO2I=NR zg`6CuP7%vN6kG-7P;}kbLUmZwS8q+tkvy=s2ObvQ2G0&xuL@X9rl<j!Om|I$4vV>4 z8lhg2pw+4QquHT15V`4vrlJ|q6udLx$$<x;nHyI}s*EGOU+AuW1jz<a4H|+Ity?vq z$I57RZUCYorm{np8YkpeK)?tH7y$vL`Fht8fNS&BSHvAD3UCzQ2v4$=XexgBFrq0m z0l^Xn@+bsLEQj2|s>KT?hl4W+oZ&DzYNT#*IN<ps)zknK!u`|KdM`Xn=c@SzxESPd z5d{}ZF~;Q><5G-qImS2(W1I;Wg>aDr8;2ID>%{7r1&$Lg#Hr%pRl18Bz{^*uq2dgc z16cf^)f*;@HQelAvJk2#LiIG5d=MscVNw*)bDPxOViETMCmNh+Ap;zd)AitpGt?$< z#IFoJ!07=_@(%SYv4N%k?#CVdwk|`|p`y3dSHzuFz#%O%UO3c-YKPQsO>h|RLF3_2 zTv?aE;j3^c_OBywxDgKH;V@n}JgG8qBk$|sq=B;xoIT*Y1kStQ9MxJDUK%)k!5M!= zP4|b2c+DRdd^>78*8eSCMwEv2e@l%sVH%`g)qW~WSE1VwbaQKgxIO=VR(%qt#hMmB zMUB=1XaY=6=Y|LUv1rBd7bj!vO)ZPIlv;k8D}j9cZvtQe=wojDF~G1+ELyRTiEK*1 zw5@j<#L7$oyx(ca@&jB1Sk%N2z}`J{SX7Xx9tI|6$OYH|80#9lbxWC@iNQ~T!l_VL z4TUav6pMXGlEID9xo>70#4k79@XDTJI4yP{QSd~utAq)0OA?1`QS6E!dJIgo{+?(p kHT){hIML`Y_68(|TN|pYiiWw&p@xaw5WgKy7>3#Z8!J^;m;e9( diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index d9bb7ffb45..595b7cf845 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -30,6 +30,7 @@ object MaterialSymbols { val AddCircle = MaterialSymbol("\uE3BA") val AddPhotoAlternate = MaterialSymbol("\uE43E") val AddReaction = MaterialSymbol("\uE1D3") + val AddToHomeScreen = MaterialSymbol("\uE1FE") val AlternateEmail = MaterialSymbol("\uE0E6") val AltRoute = MaterialSymbol("\uF184") val Apps = MaterialSymbol("\uE5C3") @@ -88,6 +89,7 @@ object MaterialSymbols { val DeleteForever = MaterialSymbol("\uE92B") val DeleteSweep = MaterialSymbol("\uE16C") val Description = MaterialSymbol("\uE873") + val DesktopWindows = MaterialSymbol("\uE30C") val DirectionsBike = MaterialSymbol("\uE52F") val DirectionsRun = MaterialSymbol("\uE566") val DirectionsWalk = MaterialSymbol("\uE536") @@ -126,6 +128,7 @@ object MaterialSymbols { val FormatItalic = MaterialSymbol("\uE23F") val FormatListNumbered = MaterialSymbol("\uE242") val FormatQuote = MaterialSymbol("\uE244") + val FormatSize = MaterialSymbol("\uE245") val FormatStrikethrough = MaterialSymbol("\uE246") val Forum = MaterialSymbol("\uE8AF") val Forward = MaterialSymbol("\uF57A") From 2111e18e529049ca05de18727173f651b385bea9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 16:15:39 +0000 Subject: [PATCH 05/14] feat(browser): Chrome-PWA parity in the :napplet browser surfaces - NappletControlSheet rebuilt on BrowserChrome: title + origin/security header (tap: page info, long-press: copy), icon row (back, forward, reload/stop, star, share), menu rows incl. rarely-used Edit address, find in page, text size, desktop site, add to Home screen, open in another browser, Privacy and Developer groups; same glyphs as Compose. - Full-screen browser: new windows for _blank/window.open (opener kept via BrowserPopups), intent: URIs, downloads (Tor-aware, MediaStore, blob/data via page script), HTML fullscreen, camera/mic/location prompts, theme-color system bars, Recents task description, long-press link/image menu, find bar, navigator.share polyfill, crash recovery. - Embedded browser service: IPC for all of the above; JS dialogs and permission prompts relayed to the main process; fullscreen inside the surface; WebView wrapped in a container so it can be rebuilt. - onRenderProcessGone handled in all four :napplet WebView owners. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../napplethost/BrowserChromeLabels.kt | 157 +++ .../amethyst/napplethost/BrowserDownloads.kt | 207 ++++ .../napplethost/BrowserExtrasScript.kt | 175 ++++ .../amethyst/napplethost/BrowserFindBar.kt | 197 ++++ .../amethyst/napplethost/BrowserJsDialogs.kt | 13 +- .../amethyst/napplethost/BrowserPopups.kt | 137 +++ .../amethyst/napplethost/BrowserWebTools.kt | 376 +++++++ .../napplethost/NappletBrowserActivity.kt | 983 ++++++++++++++---- .../napplethost/NappletBrowserContract.kt | 85 ++ .../napplethost/NappletBrowserService.kt | 464 ++++++++- .../napplethost/NappletBrowserUiAdapter.kt | 21 +- .../napplethost/NappletControlSheet.kt | 835 ++++++++------- .../napplethost/NappletHostActivity.kt | 101 +- .../napplethost/NappletHostService.kt | 44 +- .../napplethost/NappletHostUiAdapter.kt | 21 +- .../amethyst/napplethost/NappletIpc.kt | 32 + nappletHost/src/main/res/values/strings.xml | 7 - 17 files changed, 3200 insertions(+), 655 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt new file mode 100644 index 0000000000..088aac02a3 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeLabels.kt @@ -0,0 +1,157 @@ +/* + * 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/BrowserDownloads.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt new file mode 100644 index 0000000000..6e97344b45 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserDownloads.kt @@ -0,0 +1,207 @@ +/* + * 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.ContentValues +import android.content.Context +import android.os.Build +import android.os.Environment +import android.os.Handler +import android.os.Looper +import android.provider.MediaStore +import android.util.Base64 +import android.webkit.MimeTypeMap +import android.webkit.URLUtil +import android.widget.Toast +import com.vitorpamplona.quartz.utils.Log +import okhttp3.Request +import java.io.File +import java.io.OutputStream +import java.net.URLDecoder +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** + * Saves what a page downloads — `<a download>`, `Content-Disposition: attachment`, `data:` URLs, and + * `blob:` URLs (which only the page can read, so the browser-extras script hands their bytes over) — into + * the system Downloads collection, the way Chrome does. + * + * Network downloads follow the page's own route: through the Tor SOCKS proxy when the site is on Tor (OkHttp + * leaves SOCKS hosts unresolved, so even DNS goes through Tor), directly otherwise. They carry the page's + * cookies from its own per-account storage profile and its user agent, so a logged-in download works. + */ +object BrowserDownloads { + private const val TAG = "BrowserDownloads" + + /** Cap for bytes a page hands over for a blob:/data: download (they travel as base64 over the bridge). */ + const val MAX_INLINE_BYTES = 25 * 1024 * 1024 + + private val io = Executors.newSingleThreadExecutor { Thread(it, "napplet-downloads").apply { isDaemon = true } } + private val main = Handler(Looper.getMainLooper()) + + /** + * A WebView `DownloadListener` hit. [cookie] must be read on the main thread (from the tab's own + * profile) before calling; the transfer itself runs on a background thread. + */ + fun download( + context: Context, + url: String, + userAgent: String?, + contentDisposition: String?, + mimeType: String?, + cookie: String?, + proxyPort: Int, + ) { + val app = context.applicationContext + if (url.startsWith("data:", ignoreCase = true)) { + saveDataUrl(app, url, null) + return + } + if (!url.startsWith("https://", ignoreCase = true) && !url.startsWith("http://", ignoreCase = true)) return + val name = URLUtil.guessFileName(url, contentDisposition, mimeType) + toast(app, app.getString(CommonsR.string.browser_download_started, name)) + io.execute { + val ok = + runCatching { + val request = + Request + .Builder() + .url(url) + .apply { + userAgent?.takeIf { it.isNotBlank() }?.let { header("User-Agent", it) } + cookie?.takeIf { it.isNotBlank() }?.let { header("Cookie", it) } + }.get() + .build() + // No end-to-end call timeout: a large file over Tor legitimately takes minutes. The + // client's read timeout still ends a transfer that stalls completely. + val client = + NappletBlobHttp + .client(proxyPort) + .newBuilder() + .callTimeout(0, TimeUnit.SECONDS) + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) error("HTTP ${response.code}") + val type = mimeType?.takeIf { it.isNotBlank() && it != "application/octet-stream" } ?: response.body.contentType()?.let { "${it.type}/${it.subtype}" } + write(app, name, type) { out -> response.body.byteStream().use { it.copyTo(out) } } + } + }.onFailure { Log.w(TAG, "Download failed for $url", it) } + .getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) + } + } + + /** Saves a `data:` URL (`data:[mime][;base64],payload`). */ + fun saveDataUrl( + context: Context, + dataUrl: String, + suggestedName: String?, + ) { + val app = context.applicationContext + val header = dataUrl.substringBefore(',', "") + val payload = dataUrl.substringAfter(',', "") + val mime = header.removePrefix("data:").substringBefore(';').ifBlank { "application/octet-stream" } + val bytes = + runCatching { + if (header.endsWith(";base64", ignoreCase = true)) { + Base64.decode(payload, Base64.DEFAULT) + } else { + URLDecoder.decode(payload, "UTF-8").toByteArray() + } + }.getOrNull() ?: return + saveBytes(app, suggestedName, mime, bytes) + } + + /** Saves bytes a page handed over (a `blob:` download, via the browser-extras script). */ + fun saveBytes( + context: Context, + suggestedName: String?, + mimeType: String?, + bytes: ByteArray, + ) { + if (bytes.size > MAX_INLINE_BYTES) return + val app = context.applicationContext + val name = safeName(suggestedName, mimeType) + io.execute { + val ok = runCatching { write(app, name, mimeType) { it.write(bytes) } }.getOrDefault(false) + toast(app, app.getString(if (ok) CommonsR.string.browser_download_saved else CommonsR.string.browser_download_failed, name)) + } + } + + /** + * Writes into the public Downloads collection (Android 10+, no permission needed), or into the app's + * own Downloads folder on older versions, where writing the shared one would need a storage permission + * the app doesn't hold. + */ + private fun write( + context: Context, + name: String, + mimeType: String?, + body: (OutputStream) -> Unit, + ): Boolean { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val resolver = context.contentResolver + val values = + ContentValues().apply { + put(MediaStore.Downloads.DISPLAY_NAME, name) + mimeType?.let { put(MediaStore.Downloads.MIME_TYPE, it) } + put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS) + put(MediaStore.Downloads.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values) ?: return false + return try { + resolver.openOutputStream(uri)?.use(body) ?: error("No output stream") + resolver.update(uri, ContentValues().apply { put(MediaStore.Downloads.IS_PENDING, 0) }, null, null) + true + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + } + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: return false + dir.mkdirs() + File(dir, name).outputStream().use(body) + return true + } + + /** A plain filename: the page's suggestion without path parts, else "download" + the MIME's extension. */ + private fun safeName( + suggested: String?, + mimeType: String?, + ): String { + val base = + suggested + ?.substringAfterLast('/') + ?.substringAfterLast('\\') + ?.replace(Regex("[\\u0000-\\u001f:*?\"<>|]"), "_") + ?.trim() + ?.takeIf { it.isNotEmpty() && it != "." && it != ".." } + if (base != null) return base.take(120) + val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } + return if (ext != null) "download.$ext" else "download" + } + + private fun toast( + context: Context, + text: String, + ) = main.post { Toast.makeText(context, text, Toast.LENGTH_SHORT).show() } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt new file mode 100644 index 0000000000..acb3b06ec1 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserExtrasScript.kt @@ -0,0 +1,175 @@ +/* + * 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 + +/** + * A document-start script injected only into the **browser** surfaces (never into sandboxed napplets or + * nsites), filling in what an installed Chrome PWA gets and a WebView doesn't: + * + * - `navigator.share` / `navigator.canShare` — WebView has no Web Share. Text, title and url go to the + * Android share sheet (`browser.share`); files are declined, as `canShare` reports. + * - `<meta name="theme-color">` — reported (`browser.themeColor`, normalized to `rgb(r, g, b)`) whenever it + * or the colour scheme changes, so the window can tint its system bars and Recents entry. + * - `blob:` / `data:` downloads — only the page can read a `blob:` URL, so a click on (or a programmatic + * `.click()` of) an `<a download>` pointing at one is turned into its bytes (`browser.download`). + * + * Messages travel over the same origin-scoped native bridge as NIP-07; the host handles `browser.*` types + * itself and never forwards them to the broker. + */ +object BrowserExtrasScript { + val JS: String = + """ + (function () { + if (window.top !== window || window.__amethystBrowserExtras) return; + window.__amethystBrowserExtras = true; + var MAX_BYTES = ${BrowserDownloads.MAX_INLINE_BYTES}; + + function send(message) { + try { var b = window.__nappletBridge; if (b) b.postMessage(JSON.stringify(message)); } catch (_) {} + } + + // ---- Web Share ---- + if (!navigator.share) { + var hasFiles = function (data) { return !!(data && data.files && data.files.length); }; + var shareUrl = function (data) { + if (!data || data.url === undefined || data.url === null) return ''; + try { return new URL(String(data.url), document.baseURI).href; } catch (_) { return null; } + }; + navigator.share = function (data) { + data = data || {}; + var activation = navigator.userActivation; + if (activation && !activation.isActive) { + return Promise.reject(new DOMException('Must be handling a user gesture to perform a share request.', 'NotAllowedError')); + } + if (hasFiles(data)) return Promise.reject(new DOMException('Sharing files is not supported.', 'NotAllowedError')); + var url = shareUrl(data); + if (url === null) return Promise.reject(new TypeError('Invalid URL')); + if (!data.title && !data.text && !url) return Promise.reject(new TypeError('No data to share.')); + send({ type: 'browser.share', title: data.title ? String(data.title) : '', text: data.text ? String(data.text) : '', url: url }); + return Promise.resolve(); + }; + navigator.canShare = function (data) { + if (!data || hasFiles(data) || shareUrl(data) === null) return false; + return !!(data.title || data.text || data.url); + }; + } + + // ---- theme-color ---- + var lastRaw; + var lastColor; + function pickThemeColor() { + var metas = document.querySelectorAll('meta[name="theme-color"]'); + for (var i = 0; i < metas.length; i++) { + var media = metas[i].getAttribute('media'); + try { if (!media || window.matchMedia(media).matches) return metas[i].getAttribute('content'); } catch (_) {} + } + return null; + } + function normalize(color) { + if (!color) return ''; + var root = document.body || document.documentElement; + if (!root) return ''; + var probe = document.createElement('span'); + probe.style.display = 'none'; + probe.style.color = color; + if (!probe.style.color) return ''; + root.appendChild(probe); + var computed = getComputedStyle(probe).color; + probe.remove(); + return computed || ''; + } + function reportTheme() { + var raw = pickThemeColor(); + // Only a changed declaration is worth a style computation. + if (raw === lastRaw && lastColor !== undefined) return; + lastRaw = raw; + var color = normalize(raw); + if (color === lastColor) return; + lastColor = color; + send({ type: 'browser.themeColor', color: color }); + } + var themeTimer = 0; + function scheduleTheme() { clearTimeout(themeTimer); themeTimer = setTimeout(reportTheme, 50); } + function watchTheme() { + reportTheme(); + // <meta> lives in <head>: watching only there keeps busy pages (feeds re-rendering the body) + // from waking this up on every DOM change. + try { + if (document.head) { + new MutationObserver(scheduleTheme).observe(document.head, { + subtree: true, childList: true, attributes: true, attributeFilter: ['content', 'media', 'name'] + }); + } + } catch (_) {} + try { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { lastRaw = undefined; scheduleTheme(); }); + } catch (_) {} + } + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watchTheme, { once: true }); + else watchTheme(); + + // ---- blob: / data: downloads ---- + // Pages often revoke a blob URL right after clicking it, before an async fetch could read it, so + // keep a handle on each Blob until a minute after its URL is revoked (the page holds it until the + // revoke anyway, so this doesn't change its lifetime by more than that minute). + var blobs = new Map(); + try { + var nativeCreate = URL.createObjectURL; + var nativeRevoke = URL.revokeObjectURL; + URL.createObjectURL = function (obj) { + var url = nativeCreate.apply(URL, arguments); + try { if (obj instanceof Blob) blobs.set(url, obj); } catch (_) {} + return url; + }; + URL.revokeObjectURL = function (url) { + setTimeout(function () { blobs.delete(url); }, 60000); + return nativeRevoke.apply(URL, arguments); + }; + } catch (_) {} + function isInlineDownload(a) { + return !!(a && a.hasAttribute && a.hasAttribute('download') && /^(blob|data):/i.test(a.href || '')); + } + function deliver(a) { + var name = a.getAttribute('download') || ''; + var known = blobs.get(a.href); + (known ? Promise.resolve(known) : fetch(a.href).then(function (r) { return r.blob(); })).then(function (blob) { + if (blob.size > MAX_BYTES) return; + var reader = new FileReader(); + reader.onload = function () { send({ type: 'browser.download', name: name, mime: blob.type || '', data: String(reader.result) }); }; + reader.readAsDataURL(blob); + }).catch(function () {}); + } + document.addEventListener('click', function (e) { + var a = e.target && e.target.closest ? e.target.closest('a[download]') : null; + if (!isInlineDownload(a)) return; + e.preventDefault(); + deliver(a); + }, true); + // Libraries usually build a detached <a download href="blob:…"> and call .click() on it; a click + // on a detached element never reaches the document listener above. + var nativeClick = HTMLAnchorElement.prototype.click; + HTMLAnchorElement.prototype.click = function () { + if (!this.isConnected && isInlineDownload(this)) { deliver(this); return; } + return nativeClick.apply(this, arguments); + }; + })(); + """.trimIndent() +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt new file mode 100644 index 0000000000..d96a24e118 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserFindBar.kt @@ -0,0 +1,197 @@ +/* + * 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 index 69ded7e3e3..d2df5f3947 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserJsDialogs.kt @@ -28,6 +28,7 @@ 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 @@ -116,9 +117,9 @@ internal class BrowserJsDialogs( */ fun beforeUnload(result: JsResult): Boolean = show(result, onBlocked = { result.confirm() }) { answer -> - setTitle(R.string.napplet_js_leave_title) - setMessage(R.string.napplet_js_leave_message) - setPositiveButton(R.string.napplet_js_leave) { _, _ -> answer { result.confirm() } } + 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() } } } @@ -151,7 +152,7 @@ internal class BrowserJsDialogs( shownOnThisPage++ val builder = AlertDialog.Builder(activity).apply { build(answer) } if (shownOnThisPage > 1) { - builder.setNeutralButton(R.string.napplet_js_dialog_block) { _, _ -> + builder.setNeutralButton(CommonsR.string.browser_js_dialog_block) { _, _ -> blocked = true answer { onBlocked() } } @@ -173,9 +174,9 @@ internal class BrowserJsDialogs( val scheme = uri?.scheme?.lowercase() val host = uri?.host?.takeIf { it.isNotBlank() } return if (host != null && (scheme == "http" || scheme == "https")) { - activity.getString(R.string.napplet_js_dialog_title, host) + activity.getString(CommonsR.string.browser_js_dialog_title, host) } else { - activity.getString(R.string.napplet_js_dialog_title_generic) + activity.getString(CommonsR.string.browser_js_dialog_title_generic) } } } diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt new file mode 100644 index 0000000000..3a2bbd1c07 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserPopups.kt @@ -0,0 +1,137 @@ +/* + * 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.content.MutableContextWrapper +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.webkit.WebView +import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.WebMessageCompat +import androidx.webkit.WebViewCompat +import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract +import java.util.UUID + +/** + * New windows a page opens (`target="_blank"` links and user-initiated `window.open()`), handed from the + * opener's WebView to the full-screen browser window that will show them — Chrome opens these as a new + * tab, we open a new [NappletBrowserActivity] task. + * + * WebView requires the popup's WebView to exist *inside* `onCreateWindow`, before the Activity that will + * show it has started, and that WebView is what keeps `window.opener` / `postMessage` wired for OAuth + * popups. So the opener builds it here, on a [MutableContextWrapper] (re-pointed at the adopting Activity + * later), with the same storage profile, settings and scripts as any browser WebView, and parks it under a + * one-shot token passed in the launch intent. Bridge messages that arrive before the Activity adopts it are + * queued, then replayed. Unclaimed popups are destroyed after [CLAIM_TIMEOUT_MS]. + * + * Opener and popup always share the `:napplet` process (both browser surfaces live there), which is what + * makes handing a live WebView across possible. + */ +object BrowserPopups { + private const val CLAIM_TIMEOUT_MS = 30_000L + + fun interface BridgeTarget { + fun onMessage( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) + } + + class Pending internal constructor( + val webView: WebView, + val context: MutableContextWrapper, + val proxyPort: Int, + val useTor: Boolean, + val themeType: String, + val webViewProfile: String?, + ) { + private var target: BridgeTarget? = null + private val queued = mutableListOf<() -> Unit>() + + internal fun dispatch( + view: WebView, + message: WebMessageCompat, + sourceOrigin: Uri, + isMainFrame: Boolean, + replyProxy: JavaScriptReplyProxy, + ) { + val t = target + if (t != null) { + t.onMessage(view, message, sourceOrigin, isMainFrame, replyProxy) + } else { + queued += { target?.onMessage(view, message, sourceOrigin, isMainFrame, replyProxy) } + } + } + + /** Called by the adopting Activity: from now on bridge messages go to [bridge]; queued ones replay. */ + fun adopt(bridge: BridgeTarget) { + target = bridge + queued.toList().forEach { it() } + queued.clear() + } + } + + private val pending = mutableMapOf<String, Pending>() + private val main = Handler(Looper.getMainLooper()) + + /** + * Builds the popup WebView for `onCreateWindow` and parks it. Returns the token for the launch intent and + * the WebView to put on the `WebViewTransport`. + */ + fun create( + context: Context, + shimJs: String, + proxyPort: Int, + useTor: Boolean, + themeType: String, + webViewProfile: String?, + ): Pair<String, WebView> { + val app = context.applicationContext + val wrapper = MutableContextWrapper(nightThemedContext(app, themeType)) + val webView = WebView(wrapper) + // Same partition as the opener: WebView only links a popup to its opener within one profile, and + // the popup must see the same logged-in session anyway. + NappletWebViewProfile.apply(app, webView, webViewProfile) + BrowserWebTools.applyBrowserSettings(webView) + val entry = Pending(webView, wrapper, proxyPort, useTor, themeType, webViewProfile) + WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, origin, isMainFrame, reply -> + entry.dispatch(view, message, origin, isMainFrame, reply) + } + WebViewCompat.addDocumentStartJavaScript(webView, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*")) + val token = UUID.randomUUID().toString() + pending[token] = entry + main.postDelayed({ + pending.remove(token)?.let { orphan -> + orphan.webView.stopLoading() + orphan.webView.destroy() + } + }, CLAIM_TIMEOUT_MS) + return token to webView + } + + /** Hands the parked popup to the Activity that was launched for [token] (once). */ + fun take(token: String?): Pending? = token?.let { pending.remove(it) } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt new file mode 100644 index 0000000000..127bedaeda --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.kt @@ -0,0 +1,376 @@ +/* + * 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.content.ActivityNotFoundException +import android.content.ClipData +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 +import android.webkit.WebSettings +import android.webkit.WebStorage +import android.webkit.WebView +import android.widget.Toast +import androidx.core.net.toUri +import androidx.webkit.WebViewCompat +import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.quartz.utils.Log +import java.net.URISyntaxException +import java.text.DateFormat +import java.util.WeakHashMap +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** + * WebView-side behaviours shared by the full-screen browser ([NappletBrowserActivity]) and the embedded + * one ([NappletBrowserService]), so the two surfaces act the same: non-web schemes, desktop mode, text + * size, the out-of-scope "back to app" walk, site-data clearing, page info, copy and share. + * + * Everything here runs in the keyless `:napplet` process and touches only the WebView it is given. + */ +object BrowserWebTools { + private const val TAG = "BrowserWebTools" + + // ---- setup ---- + + /** + * The settings every browser WebView gets (full-screen, embedded, and popups), so a page behaves the + * same wherever it opens. New windows are enabled — `onCreateWindow` turns them into new browser + * windows — but `window.open()` still needs a user gesture (Blink's popup blocker, as in Chrome). + * Geolocation is enabled at the WebView level; every request still goes through the per-site prompt. + */ + @Suppress("SetJavaScriptEnabled") + fun applyBrowserSettings(webView: WebView) { + webView.settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + @Suppress("DEPRECATION") + databaseEnabled = false + allowFileAccess = false + allowContentAccess = false + @Suppress("DEPRECATION") + allowFileAccessFromFileURLs = false + @Suppress("DEPRECATION") + allowUniversalAccessFromFileURLs = false + javaScriptCanOpenWindowsAutomatically = false + setSupportMultipleWindows(true) + setGeolocationEnabled(true) + mediaPlaybackRequiresUserGesture = true + builtInZoomControls = true + displayZoomControls = false + loadWithOverviewMode = true + useWideViewPort = true + mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE + if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { + safeBrowsingEnabled = true + } + } + WebView.setWebContentsDebuggingEnabled(false) + } + + /** + * The document-start script for a browser WebView: the direct-bridge flags, the NIP-07 [shimJs], and + * [BrowserExtrasScript]. [imeProxy] is set only for the embedded surface, which has no native keyboard. + */ + fun browserStartScript( + shimJs: String, + imeProxy: Boolean, + ): String { + val flags = if (imeProxy) " window.__nappletImeProxy = true;" else "" + return "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true;$flags }\n$shimJs\n${BrowserExtrasScript.JS}" + } + + // ---- non-web schemes ---- + + /** + * Handles a navigation to a non-http(s) [uri] the way Chrome does: `intent:` URIs are parsed (component + * and selector stripped so a page can't aim at a private activity) and fall back to their + * `browser_fallback_url` in-page when no app takes them; other schemes (`mailto:`, `tel:`, `geo:`, + * `nostr:`, …) go to the system. Only acts on a user gesture, like Chrome, so a page can't bounce the + * user into another app on load. Always consumes the navigation. + */ + fun openExternal( + context: Context, + uri: Uri, + hasGesture: Boolean, + loadInPage: (String) -> Unit, + ): Boolean { + if (!hasGesture) return true + val intent = + if (uri.scheme.equals("intent", ignoreCase = true)) { + parseIntentUri(uri.toString()) ?: return true + } else { + Intent(Intent.ACTION_VIEW, uri) + } + intent.addCategory(Intent.CATEGORY_BROWSABLE) + if (context !is Activity) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + try { + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + val fallback = intent.getStringExtra("browser_fallback_url") + if (fallback != null && (fallback.startsWith("https://") || fallback.startsWith("http://"))) { + loadInPage(fallback) + } + } catch (e: Exception) { + Log.w(TAG, "Could not open ${uri.scheme} link", e) + } + return true + } + + /** Parses an `intent:` URI with the same hardening Chrome applies. Null when it's malformed. */ + fun parseIntentUri(uri: String): Intent? = + try { + Intent.parseUri(uri, Intent.URI_INTENT_SCHEME).apply { + // A web page may only ask for something any app could handle: never an explicit + // component, never a selector (which could smuggle one in). + component = null + selector = null + // No grants of our own content to whatever answers. + flags = flags and + ( + Intent.FLAG_GRANT_READ_URI_PERMISSION or + Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or + Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + ).inv() + } + } catch (_: URISyntaxException) { + null + } + + // ---- desktop site / text size ---- + + private val mobileUserAgents = WeakHashMap<WebView, String>() + + fun isDesktopMode(webView: WebView): Boolean = mobileUserAgents.containsKey(webView) + + /** Switches [webView] between its own mobile UA and [BrowserChrome.desktopUserAgent], then reloads. */ + fun setDesktopMode( + webView: WebView, + desktop: Boolean, + ) { + if (desktop == isDesktopMode(webView)) return + val settings = webView.settings + if (desktop) { + val mobile = settings.userAgentString + mobileUserAgents[webView] = mobile + settings.userAgentString = BrowserChrome.desktopUserAgent(mobile) + settings.useWideViewPort = true + settings.loadWithOverviewMode = true + } else { + settings.userAgentString = mobileUserAgents.remove(webView) + } + webView.reload() + } + + fun setTextZoom( + webView: WebView, + percent: Int, + ) { + webView.settings.textZoom = percent.coerceIn(BrowserChrome.TEXT_ZOOM_STEPS.first(), BrowserChrome.TEXT_ZOOM_STEPS.last()) + } + + // ---- scope ---- + + /** + * Chrome's out-of-scope bar ✕: step back to the most recent history entry on [startUrl]'s origin, or + * reload [startUrl] when there is none. + */ + fun backToScope( + webView: WebView, + startUrl: String, + ) { + val home = BrowserChrome.originOf(startUrl) ?: return + val history = webView.copyBackForwardList() + for (i in history.currentIndex - 1 downTo 0) { + if (BrowserChrome.originOf(history.getItemAtIndex(i).url).equals(home, ignoreCase = true)) { + webView.goBackOrForward(i - history.currentIndex) + return + } + } + webView.loadUrl(startUrl) + } + + // ---- storage ---- + + /** The cookie jar of [webView]'s own storage profile (the per-account one), or the default jar. */ + fun cookieManager(webView: WebView): CookieManager = + if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) { + runCatching { WebViewCompat.getProfile(webView).cookieManager }.getOrNull() ?: CookieManager.getInstance() + } else { + CookieManager.getInstance() + } + + private fun webStorage(webView: WebView): WebStorage = + if (WebViewFeature.isFeatureSupported(WebViewFeature.MULTI_PROFILE)) { + runCatching { WebViewCompat.getProfile(webView).webStorage }.getOrNull() ?: WebStorage.getInstance() + } else { + WebStorage.getInstance() + } + + /** + * Clears what the site behind [url] stored in [webView]'s profile — its origin's web storage (local + * storage, IndexedDB, cache storage, service workers) and its cookies — then reloads it logged out. + * Other sites and other accounts are untouched. + */ + fun clearSiteData( + context: Context, + webView: WebView, + url: String, + ) { + val origin = BrowserChrome.originOf(url) ?: return + runCatching { webStorage(webView).deleteOrigin(origin) } + val cookies = cookieManager(webView) + val names = + cookies + .getCookie(url) + .orEmpty() + .split(';') + .mapNotNull { it.substringBefore('=').trim().takeIf(String::isNotEmpty) } + val host = BrowserChrome.displayHost(url) + // A cookie can be scoped to the host or to any parent domain; expire it on each so it really goes. + val domains = host.split('.').let { parts -> (0 until (parts.size - 1).coerceAtLeast(1)).map { parts.drop(it).joinToString(".") } } + names.forEach { name -> + cookies.setCookie(url, "$name=; Max-Age=0; Path=/") + domains.forEach { domain -> cookies.setCookie(url, "$name=; Max-Age=0; Path=/; Domain=$domain") } + } + cookies.flush() + Toast.makeText(context, CommonsR.string.browser_site_data_cleared, Toast.LENGTH_SHORT).show() + webView.reload() + } + + // ---- 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<String>() + lines += + context.getString( + if (url.startsWith("https://", ignoreCase = true)) CommonsR.string.browser_page_info_https else CommonsR.string.browser_page_info_http, + ) + 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 ---- + + fun copyToClipboard( + context: Context, + text: String, + ) { + val clipboard = context.getSystemService(ClipboardManager::class.java) ?: return + clipboard.setPrimaryClip(ClipData.newPlainText(text, text)) + // Android 13+ shows its own clipboard confirmation; a toast on top of it would be noise. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + Toast.makeText(context, CommonsR.string.browser_link_copied, Toast.LENGTH_SHORT).show() + } + } + + private var lastShareAt = 0L + + /** + * Opens the Android share sheet for a page or a `navigator.share()` call. Throttled: a page can post + * share requests straight to the bridge (bypassing the polyfill's user-activation check), so at most + * one sheet per [SHARE_COOLDOWN_MS] is honoured. + */ + fun share( + context: Context, + title: String?, + text: String?, + url: String?, + ) { + val now = SystemClock.elapsedRealtime() + if (now - lastShareAt < SHARE_COOLDOWN_MS) return + lastShareAt = now + val body = listOfNotNull(text?.takeIf { it.isNotBlank() }, url?.takeIf { it.isNotBlank() }).joinToString("\n") + if (body.isEmpty()) return + val send = + Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, body) + title?.takeIf { it.isNotBlank() }?.let { putExtra(Intent.EXTRA_SUBJECT, it) } + } + val chooser = Intent.createChooser(send, context.getString(CommonsR.string.browser_share_chooser)) + if (context !is Activity) chooser.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + runCatching { context.startActivity(chooser) }.onFailure { Log.w(TAG, "Share failed", it) } + } + + /** + * Hands [url] to a browser other than Amethyst — the escape hatch for sites that refuse embedded + * browsers (Google sign-in, some banks). Our own activities are excluded from the chooser. + */ + fun openInOtherBrowser( + context: Context, + url: String, + ) { + val view = Intent(Intent.ACTION_VIEW, url.toUri()).addCategory(Intent.CATEGORY_BROWSABLE) + val chooser = + Intent.createChooser(view, null).apply { + putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, ownBrowsableComponents(context, view)) + if (context !is Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(chooser) + } catch (_: ActivityNotFoundException) { + Toast.makeText(context, CommonsR.string.browser_no_other_browser, Toast.LENGTH_SHORT).show() + } + } + + private fun ownBrowsableComponents( + context: Context, + intent: Intent, + ): Array<ComponentName> = + runCatching { + @Suppress("DEPRECATION") + context.packageManager + .queryIntentActivities(intent, 0) + .filter { it.activityInfo.packageName == context.packageName } + .map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) } + .toTypedArray() + }.getOrDefault(emptyArray()) + + private const val SHARE_COOLDOWN_MS = 1_000L +} 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 473ccb0e6f..13b8c3069d 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -20,33 +20,45 @@ */ 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.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.Icon import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.Message import android.os.Messenger +import android.util.TypedValue +import android.view.ContextMenu import android.view.Gravity import android.view.View import android.view.ViewGroup import android.webkit.ConsoleMessage +import android.webkit.GeolocationPermissions import android.webkit.JsPromptResult import android.webkit.JsResult +import android.webkit.PermissionRequest +import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse -import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.Button import android.widget.FrameLayout import android.widget.LinearLayout import android.widget.ProgressBar @@ -54,15 +66,24 @@ import android.widget.TextView import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback +import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils import androidx.core.graphics.scale import androidx.core.net.toUri +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsControllerCompat import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.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.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -77,35 +98,50 @@ import com.vitorpamplona.amethyst.commons.R as CommonsR /** * Full-screen **direct-WebView** browser for an arbitrary URL, running in the keyless `:napplet` - * process. Unlike the embedded browser ([NappletBrowserService], which streams its surface to the main - * app through SurfaceControlViewHost — a path that, on current Android, forwards taps but drops scroll/ - * zoom/keyboard gestures), this hosts the WebView **directly** in its own window, so scrolling, pinch - * zoom, and the soft keyboard all work natively — the window insets the content for the IME itself (see - * [applyFullScreenHostInsets]). It stays just as keyless: the page JS runs here, every NIP-07 - * `window.nostr` call is brokered + consent-gated in the main process per origin, and the keys never - * leave it. + * process — Amethyst's equivalent of an installed Chrome PWA window. Unlike the embedded browser + * ([NappletBrowserService], which streams its surface to the main app through SurfaceControlViewHost — a + * path that, on current Android, forwards taps but drops scroll/zoom/keyboard gestures), this hosts the + * WebView **directly** in its own window, so scrolling, pinch zoom, and the soft keyboard all work natively + * — the window insets the content for the IME itself (see [applyFullScreenHostInsets]). It stays just as + * keyless: the page JS runs here, every NIP-07 `window.nostr` call is brokered + consent-gated in the main + * process per origin, and the keys never leave it. * - * Mirrors [NappletHostActivity]'s sandbox scaffolding (trusted chrome, loading screen, foreground hold) - * but loads a live URL directly instead of serving verified blobs through a shell. + * 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`) + * 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. */ class NappletBrowserActivity : ComponentActivity() { - private lateinit var webView: WebView + private var webView: WebView? = null private var startUrl: String = "about:blank" private var proxyPort: Int = -1 private var useTor: Boolean = true private var themeType: String = "SYSTEM" + private var webViewProfile: String? = null + + // Key for this window's foreground lease with the broker; stable for the Activity's life. + private var leaseKey: String = "" private val contentFrame by lazy { FrameLayout(this) } + private var root: FrameLayout? = null 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 // A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome // client's onProgressChanged; hidden at 100%. private val topProgressBar by lazy { buildTopProgressBar() } + // The NIP-07 shim + browser extras, injected at document start in this window and in its popups. + private var shimJs: String = "" + // Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/ // unresolved address never enters history. Reset on each main-frame page start. private var pendingMainFrameUrl: String? = null @@ -116,6 +152,15 @@ class NappletBrowserActivity : ComponentActivity() { // same address don't each trigger a round-trip. private var lastFavoriteQueryUrl: String? = null + // What Recents shows for this task: the page's title, favicon and theme colour. + private var pageTitle: String? = null + private var pageIcon: Bitmap? = null + private var themeColor: Int? = null + + // HTML fullscreen (a video's fullscreen button): the view WebView hands us, drawn over the whole window. + 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) } @@ -127,6 +172,17 @@ class NappletBrowserActivity : ComponentActivity() { private val fileChooserLauncher = WebFileChooserLauncher(this) { uris -> pendingFileChooser.deliver(uris) } + // ---- site permissions (camera / microphone / location) ---- + private val sitePermissionQueries = mutableMapOf<Long, (Map<BrowserSitePermission, Decision>) -> Unit>() + private var sitePermissionSeq = 0L + private var permissionPrompt: AlertDialog? = null + private var pendingRuntimeGrant: ((Map<String, Boolean>) -> Unit)? = null + private val runtimePermissionLauncher = + registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result -> + pendingRuntimeGrant?.invoke(result) + pendingRuntimeGrant = null + } + // ---- broker bridge (per-origin NIP-07 tokens; identical to NappletBrowserService) ---- private var brokerMessenger: Messenger? = null @@ -163,20 +219,30 @@ class NappletBrowserActivity : ComponentActivity() { private val pendingByOrigin = mutableMapOf<String, MutableList<Message>>() private val mintInFlight = mutableSetOf<String>() + /** + * Back walks out of fullscreen video, then the find bar, then the page's history, then leaves. Enabled + * only while one of those applies, so the system back (and its predictive animation) otherwise acts + * on the window itself. + */ private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (this@NappletBrowserActivity::webView.isInitialized && webView.canGoBack()) { - webView.goBack() - } else { - isEnabled = false - onBackPressedDispatcher.onBackPressed() + val wv = webView + when { + customView != null -> exitFullscreen() + findBar?.isShowing == true -> findBar?.hide() + wv != null && wv.canGoBack() -> wv.goBack() + else -> { + isEnabled = false + onBackPressedDispatcher.onBackPressed() + } } + syncBackState() } } private fun syncBackState() { - if (this::webView.isInitialized) backCallback.isEnabled = webView.canGoBack() + backCallback.isEnabled = customView != null || findBar?.isShowing == true || webView?.canGoBack() == true } private val brokerConnection = @@ -199,14 +265,32 @@ class NappletBrowserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run { + // A new window a page opened: its WebView already exists (built inside the opener's onCreateWindow). + val popupToken = intent.getStringExtra(EXTRA_POPUP_TOKEN) + val popup = BrowserPopups.take(popupToken) + if (popupToken != null && popup == null) { finish() return } - proxyPort = intent.getIntExtra(EXTRA_PROXY_PORT, -1) - useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true) + + if (popup != null) { + proxyPort = popup.proxyPort + useTor = popup.useTor + themeType = popup.themeType + webViewProfile = popup.webViewProfile + leaseKey = "popup:$popupToken" + } else { + startUrl = intent.getStringExtra(EXTRA_URL)?.takeIf { it.isNotBlank() } ?: run { + finish() + return + } + proxyPort = intent.getIntExtra(EXTRA_PROXY_PORT, -1) + useTor = intent.getBooleanExtra(EXTRA_USE_TOR, true) + themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" } + webViewProfile = intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE) + leaseKey = startUrl + } title = intent.getStringExtra(EXTRA_TITLE).orEmpty() - themeType = intent.getStringExtra(EXTRA_THEME).orEmpty().ifBlank { "SYSTEM" } if (!WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) { Toast.makeText(this, getString(R.string.napplet_webview_too_old), Toast.LENGTH_LONG).show() @@ -214,61 +298,80 @@ class NappletBrowserActivity : ComponentActivity() { return } - // Build the WebView from a context forced to the app theme so its content follows DARK/LIGHT even when - // the device theme differs (WebView reads the context's theme, not the window's — see nightThemedContext). - webView = WebView(nightThemedContext(this, themeType)) - // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its - // profile has otherwise been used), so the storage partition must be chosen before anything else. - NappletWebViewProfile.apply(this, webView, intent.getStringExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE)) - configureWebView(webView) - webView.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) - webView.dropSystemBarInsets() + shimJs = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() applyWebViewProxy(if (useTor) proxyPort else -1) - // NIP-07 over the direct bridge (no shell): the shim talks to native at document start for every - // origin; the broker scopes consent per visited origin. - val shim = readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() - WebViewCompat.addWebMessageListener(webView, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onBridgeMessage) - val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; }\n$shim" - WebViewCompat.addDocumentStartJavaScript(webView, startScript, setOf("*")) - 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(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 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 - // this the keyboard covers the bottom of the page. See applyFullScreenHostInsets. + // this the keyboard covers the bottom of the page. See applyFullScreenHostInsets. The root's own + // background shows through that padding, which is how the page's theme-color tints the bars. root.applyFullScreenHostInsets() - contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) - loadingView = buildLoadingView().also { contentFrame.addView(it) } + val wv = buildWebView(popup) + contentFrame.addView(wv, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + if (popup == null) { + loadingView = buildLoadingView().also { contentFrame.addView(it) } + wv.loadUrl(startUrl) + } else { + wv.url?.let { if (it.isNotBlank() && it != "about:blank") startUrl = it } + } + updateTaskDescription() + } - webView.loadUrl(startUrl) + /** + * Builds (or, for a popup, adopts) this window's WebView and wires every client and bridge. Also used + * to rebuild after a renderer crash. + */ + private fun buildWebView(popup: BrowserPopups.Pending? = null): WebView { + val wv: WebView + if (popup != null) { + wv = popup.webView + // The popup was built on a placeholder context; point it at this Activity now. + popup.context.baseContext = nightThemedContext(this, themeType) + } else { + // Build the WebView from a context forced to the app theme so its content follows DARK/LIGHT + // even when the device theme differs (WebView reads the context's theme, not the window's). + wv = WebView(nightThemedContext(this, themeType)) + // FIRST touch after construction: setProfile throws once the WebView has loaded content (or + // its profile has otherwise been used), so the storage partition must be chosen first. + NappletWebViewProfile.apply(this, wv, webViewProfile) + } + BrowserWebTools.applyBrowserSettings(wv) + wv.webViewClient = BrowserClient() + wv.webChromeClient = BrowserChromeClient() + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> + BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), if (useTor) proxyPort else -1) + } + wv.setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) + wv.dropSystemBarInsets() + registerForContextMenu(wv) + + if (popup != null) { + popup.adopt(::onBridgeMessage) + } else { + // NIP-07 over the direct bridge (no shell): the shim talks to native at document start for + // every origin; the broker scopes consent per visited origin. + WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*"), ::onBridgeMessage) + WebViewCompat.addDocumentStartJavaScript(wv, BrowserWebTools.browserStartScript(shimJs, imeProxy = false), setOf("*")) + } + webView = wv + return wv } // Renews the broker's foreground lease while resumed; without it the broker's watchdog would reap the @@ -284,22 +387,18 @@ class NappletBrowserActivity : ComponentActivity() { override fun onResume() { super.onResume() - if (this::webView.isInitialized) { - webView.onResume() - } + webView?.onResume() resumed = true heartbeatHandler.removeCallbacks(heartbeat) heartbeat.run() } override fun onPause() { - if (this::webView.isInitialized) { - // Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is - // process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including - // the embedded ones in NappletBrowserService, which have no resume of their own. That left the - // embed frozen (dead page/connection) after returning from a full-screen excursion. - webView.onPause() - } + // Only pause THIS activity's WebView (onPause is per-WebView). Do NOT call pauseTimers(): it is + // process-global — it freezes JS/layout/parsing timers for EVERY WebView in `:napplet`, including + // the embedded ones in NappletBrowserService, which have no resume of their own. That left the + // embed frozen (dead page/connection) after returning from a full-screen excursion. + webView?.onPause() resumed = false heartbeatHandler.removeCallbacks(heartbeat) setBrokerForeground(false) @@ -317,27 +416,35 @@ class NappletBrowserActivity : ComponentActivity() { fileChooserLauncher.teardown() // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. jsDialogs.dismiss() - if (this::webView.isInitialized) { - // Detach from the view tree BEFORE destroy(). Destroying a WebView while it is still attached to - // the window corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER - // (embedded) WebViews living in this `:napplet` process: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads - // returning empty (`value == ""` on a field that visibly shows text), dead selection-highlight paint, - // and broken IME — all after a full-screen excursion returns to an embed. (`destroy()` requires the - // view to be removed from the hierarchy first; see WebView.destroy() docs.) - webView.stopLoading() - (webView.parent as? ViewGroup)?.removeView(webView) - webView.destroy() - } + permissionPrompt?.dismiss() + customViewCallback?.onCustomViewHidden() + destroyWebView() super.onDestroy() } + /** + * Detaches, then destroys, the WebView. Destroying a WebView while it is still attached to the window + * corrupts the SHARED multiprocess renderer/network state, which then breaks the OTHER (embedded) + * WebViews living in this `:napplet` process: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads returning + * empty, dead selection-highlight paint, and broken IME — all after a full-screen excursion returns to + * an embed. (`destroy()` requires the view to be removed from the hierarchy first.) + */ + private fun destroyWebView() { + val wv = webView ?: return + webView = null + unregisterForContextMenu(wv) + wv.stopLoading() + (wv.parent as? ViewGroup)?.removeView(wv) + wv.destroy() + } + /** Reports foreground state to the broker so the main process stays resumed (Tor/relays/AUTH). */ private fun setBrokerForeground(foreground: Boolean) { val msg = Message.obtain(null, NappletIpc.MSG_SET_FOREGROUND).apply { data = Bundle().apply { - putString(NappletIpc.KEY_LAUNCH_TOKEN, startUrl) + putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) putBoolean(NappletIpc.KEY_FOREGROUND, foreground) } } @@ -354,43 +461,14 @@ class NappletBrowserActivity : ComponentActivity() { val msg = Message.obtain(null, NappletIpc.MSG_RELEASE_CLIENT).apply { replyTo = replyMessenger - data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, startUrl) } + data = Bundle().apply { putString(NappletIpc.KEY_LAUNCH_TOKEN, leaseKey) } } runCatching { broker.send(msg) } } - @Suppress("SetJavaScriptEnabled") - private fun configureWebView(wv: WebView) { - wv.settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - @Suppress("DEPRECATION") - databaseEnabled = false - allowFileAccess = false - allowContentAccess = false - @Suppress("DEPRECATION") - allowFileAccessFromFileURLs = false - @Suppress("DEPRECATION") - allowUniversalAccessFromFileURLs = false - javaScriptCanOpenWindowsAutomatically = false - setSupportMultipleWindows(false) - setGeolocationEnabled(false) - mediaPlaybackRequiresUserGesture = true - builtInZoomControls = true - displayZoomControls = false - loadWithOverviewMode = true - useWideViewPort = true - mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { - safeBrowsingEnabled = true - } - } - WebView.setWebContentsDebuggingEnabled(false) - wv.webViewClient = BrowserClient() - wv.webChromeClient = BrowserChromeClient() - } + private fun currentUrl(): String = webView?.url?.takeIf { it.isNotBlank() } ?: controlSheet?.state?.url?.takeIf { it.isNotBlank() } ?: startUrl - /** Captures favicon and console output, drives the top loading bar, and opens the file picker. */ + /** Captures favicon, title and console output, and hosts every page-initiated UI. */ private inner class BrowserChromeClient : WebChromeClient() { /** * Without this override the base implementation returns false and WebView shows nothing at all, so @@ -414,7 +492,35 @@ class NappletBrowserActivity : ComponentActivity() { view: WebView, title: String?, ) { + pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url } controlSheet?.updateTitle(title) + updateTaskDescription() + } + + override fun onReceivedIcon( + view: WebView, + icon: Bitmap?, + ) { + if (icon == null || mainFrameLoadFailed) return + pageIcon = icon + updateTaskDescription() + val host = OmniboxInput.hostOf(view.url ?: return) ?: return + // De-dupe: a page can fire this several times — store once per host per visit. + if (host == lastIconHost) return + lastIconHost = host + recordIcon(host, icon) + } + + 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) + return true } // The framework's own JS dialogs only appear when the WebView's context IS an Activity @@ -450,29 +556,57 @@ class NappletBrowserActivity : ComponentActivity() { result: JsResult, ): Boolean = jsDialogs.beforeUnload(result) - override fun onReceivedIcon( + /** + * A `_blank` link or a user-initiated `window.open()`: open it as a new browser window, like a new + * Chrome tab. Without a user gesture it's refused (Chrome's popup blocker). + */ + override fun onCreateWindow( view: WebView, - icon: Bitmap?, - ) { - if (icon == null || mainFrameLoadFailed) return - val host = OmniboxInput.hostOf(view.url ?: return) ?: return - // De-dupe: a page can fire this several times — store once per host per visit. - if (host == lastIconHost) return - lastIconHost = host - recordIcon(host, icon) - } - - 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) + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message, + ): Boolean { + if (!isUserGesture) return false + val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false + val (token, child) = BrowserPopups.create(this@NappletBrowserActivity, shimJs, proxyPort, useTor, themeType, webViewProfile) + transport.webView = child + resultMsg.sendToTarget() + startActivity(popupIntent(this@NappletBrowserActivity, token)) return true } + + /** `window.close()` from a window a page opened: close this window. */ + override fun onCloseWindow(window: WebView) { + if (window === webView) finish() + } + + override fun onPermissionRequest(request: PermissionRequest) = handlePermissionRequest(request) + + override fun onPermissionRequestCanceled(request: PermissionRequest) { + permissionPrompt?.dismiss() + } + + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback, + ) { + val siteOrigin = BrowserChrome.originOf(origin) ?: return callback.invoke(origin, false, false) + resolveSitePermissions(siteOrigin, listOf(BrowserSitePermission.LOCATION)) { granted -> + // Never let WebView remember it: the answer lives in the main-process registry. + callback.invoke(origin, BrowserSitePermission.LOCATION in granted, false) + } + } + + override fun onGeolocationPermissionsHidePrompt() { + permissionPrompt?.dismiss() + } + + override fun onShowCustomView( + view: View, + callback: CustomViewCallback, + ) = enterFullscreen(view, callback) + + override fun onHideCustomView() = exitFullscreen() } /** @@ -503,10 +637,7 @@ class NappletBrowserActivity : ComponentActivity() { val uri = request.url val scheme = uri.scheme?.lowercase() if (scheme == "http" || scheme == "https") return false - if (request.hasGesture()) { - runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - } - return true + return BrowserWebTools.openExternal(this@NappletBrowserActivity, uri, request.hasGesture()) { view.loadUrl(it) } } override fun onPageStarted( @@ -517,10 +648,19 @@ class NappletBrowserActivity : ComponentActivity() { // A fresh main-frame navigation: arm history gating and show the new address. pendingMainFrameUrl = url mainFrameLoadFailed = false + // A window a page opened takes its first real page as its home ("scope"). + if (startUrl == "about:blank" && url.startsWith("http")) startUrl = url // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. - if (OmniboxInput.hostOf(url) != lastIconHost) lastIconHost = null + if (OmniboxInput.hostOf(url) != lastIconHost) { + lastIconHost = null + pageIcon = null + pageTitle = null + themeColor = null + applyThemeColor(null) + } // Chrome scopes "block this page's dialogs" to the page: a new main-frame load lifts it. jsDialogs.onMainFrameNavigation() + controlSheet?.setLoading(true) showUrl(url) } @@ -558,7 +698,7 @@ class NappletBrowserActivity : ComponentActivity() { url: String, isReload: Boolean, ) { - syncBackState() + syncNavigation(view) showUrl(url) } @@ -566,7 +706,8 @@ class NappletBrowserActivity : ComponentActivity() { view: WebView, url: String, ) { - syncBackState() + syncNavigation(view) + controlSheet?.setLoading(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://"))) { @@ -574,6 +715,33 @@ class NappletBrowserActivity : ComponentActivity() { scheduleFaviconSniff(view, url) } } + + /** + * The renderer died (crashed or was killed for memory). Every WebView in `:napplet` shares one + * renderer, and returning false here would kill the whole process — taking every embedded tab with + * it. So drop just this WebView and offer a reload, like Chrome's "Aw, Snap!". + */ + override fun onRenderProcessGone( + view: WebView, + detail: RenderProcessGoneDetail, + ): Boolean { + if (view !== webView) { + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + return true + } + val lastUrl = view.url?.takeIf { it.startsWith("http") } ?: startUrl + Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a reload of $lastUrl" } + exitFullscreen() + destroyWebView() + showCrashView(lastUrl) + return true + } + } + + private fun syncNavigation(view: WebView) { + syncBackState() + controlSheet?.setNavigation(view.canGoBack(), view.canGoForward()) } /** @@ -589,7 +757,7 @@ class NappletBrowserActivity : ComponentActivity() { replyTo = replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) } } - if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + queueToBroker(msg) } /** @@ -604,7 +772,7 @@ class NappletBrowserActivity : ComponentActivity() { ) { val host = OmniboxInput.hostOf(url) ?: return view.postDelayed({ - if (mainFrameLoadFailed || host == lastIconHost || view.url != url) return@postDelayed + if (view !== webView || mainFrameLoadFailed || host == lastIconHost || view.url != url) return@postDelayed NappletFaviconSniffer.capture(view) { sniffedHost, bytes -> if (sniffedHost == lastIconHost) return@capture lastIconHost = sniffedHost @@ -626,7 +794,7 @@ class NappletBrowserActivity : ComponentActivity() { putString(NappletIpc.KEY_HISTORY_TITLE, title.orEmpty()) } } - if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + queueToBroker(msg) } /** Scales [icon] down and relays it to the broker as the favicon for [host] (PNG bytes over IPC). */ @@ -636,12 +804,7 @@ class NappletBrowserActivity : ComponentActivity() { ) { val bytes = runCatching { - val scaled = - if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) { - icon.scale(ICON_MAX_PX, ICON_MAX_PX) - } else { - icon - } + val scaled = if (icon.width > ICON_MAX_PX || icon.height > ICON_MAX_PX) icon.scale(ICON_MAX_PX, ICON_MAX_PX) else icon ByteArrayOutputStream().use { out -> scaled.compress(Bitmap.CompressFormat.PNG, 100, out) out.toByteArray() @@ -663,17 +826,18 @@ class NappletBrowserActivity : ComponentActivity() { putByteArray(NappletIpc.KEY_ICON_BYTES, bytes) } } - if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + queueToBroker(msg) } - /** Loads a user-typed address from the in-page address bar, forcing Tor for `.onion` when available. */ + /** Loads a user-typed address from "Edit address", forcing Tor for `.onion` when available. */ private fun loadAddress(text: String) { val resolved = OmniboxInput.resolve(text) ?: return if (resolved.forceTor && proxyPort > 0 && !useTor) { useTor = true applyWebViewProxy(proxyPort) + controlSheet?.setTor(true) } - if (this::webView.isInitialized) webView.loadUrl(resolved.url) + webView?.loadUrl(resolved.url) } // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- @@ -690,6 +854,12 @@ class NappletBrowserActivity : ComponentActivity() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return + // Browser conveniences (share, theme colour, blob downloads) are handled here, never brokered. + if (envelope.stringOrNull("type").orEmpty().startsWith("browser.")) { + onBrowserMessage(envelope) + return + } + val scheme = sourceOrigin.scheme ?: return val host = sourceOrigin.host ?: return val origin = "$scheme://$host" + if (sourceOrigin.port > 0) ":${sourceOrigin.port}" else "" @@ -708,13 +878,34 @@ class NappletBrowserActivity : ComponentActivity() { val token = originTokens[origin] if (token != null) { msg.data.putString(NappletIpc.KEY_LAUNCH_TOKEN, token) - if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + queueToBroker(msg) } else { pendingByOrigin.getOrPut(origin) { mutableListOf() }.add(msg) requestBrowserToken(origin) } } + /** A `browser.*` message from [BrowserExtrasScript]. */ + private fun onBrowserMessage(envelope: JsonObject) { + when (envelope.stringOrNull("type")) { + "browser.share" -> + if (resumed) { + BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) + } + "browser.themeColor" -> { + themeColor = BrowserChrome.parseCssRgb(envelope.stringOrNull("color")) + applyThemeColor(themeColor) + updateTaskDescription() + } + "browser.download" -> { + val data = envelope.stringOrNull("data") ?: return + if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { + BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) + } + } + } + } + private fun requestBrowserToken(origin: String) { if (!mintInFlight.add(origin)) return val msg = @@ -722,7 +913,12 @@ class NappletBrowserActivity : ComponentActivity() { replyTo = replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } } - if (brokerMessenger == null) pendingBrokerRequests.add(msg) else sendToBroker(msg) + queueToBroker(msg) + } + + /** Sends now when the broker is bound, else queues until it is. */ + private fun queueToBroker(msg: Message) { + if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) } private fun sendToBroker(msg: Message) { @@ -750,6 +946,14 @@ class NappletBrowserActivity : ComponentActivity() { val url = data.getString(NappletIpc.KEY_FAVORITE_URL) ?: return true controlSheet?.setFavorite(url, data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, false)) } + NappletIpc.MSG_SITE_PERMISSIONS -> { + val callback = sitePermissionQueries.remove(data.getLong(NappletIpc.KEY_REQUEST_ID)) ?: return true + callback( + BrowserSitePermission.entries.associateWith { permission -> + runCatching { Decision.valueOf(data.getString(NappletIpc.KEY_SITE_PERMISSION_PREFIX + permission.key).orEmpty()) }.getOrDefault(Decision.ASK) + }, + ) + } NappletIpc.MSG_BROWSER_TOKEN -> { val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN) ?: return true val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true @@ -765,6 +969,298 @@ class NappletBrowserActivity : ComponentActivity() { return true } + // ---- site permissions ---- + + private fun handlePermissionRequest(request: PermissionRequest) { + val origin = BrowserChrome.originOf(request.origin.toString()) ?: return request.deny() + val wanted = request.resources.mapNotNull(::sitePermissionFor).distinct() + if (wanted.isEmpty()) return request.deny() + resolveSitePermissions(origin, wanted) { granted -> + val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray() + if (resources.isEmpty()) request.deny() else request.grant(resources) + } + } + + private fun sitePermissionFor(resource: String): BrowserSitePermission? = + when (resource) { + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> BrowserSitePermission.CAMERA + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> BrowserSitePermission.MICROPHONE + else -> null + } + + /** + * Decides [wanted] for [origin]: the user's remembered answers first (kept per origin in the main + * process — the same on Tor and the open web), a prompt for anything never answered, and finally + * Android's own runtime permission for whatever was allowed. [done] receives what is granted. + */ + private fun resolveSitePermissions( + origin: String, + wanted: List<BrowserSitePermission>, + done: (Set<BrowserSitePermission>) -> Unit, + ) { + querySitePermissions(origin) { decisions -> + val allowed = wanted.filter { decisions[it] == Decision.ALLOW }.toSet() + val ask = wanted.filter { decisions[it] == Decision.ASK } + 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) + } + } + } + } + + private fun querySitePermissions( + origin: String, + callback: (Map<BrowserSitePermission, Decision>) -> Unit, + ) { + val id = ++sitePermissionSeq + sitePermissionQueries[id] = callback + val msg = + Message.obtain(null, NappletIpc.MSG_QUERY_SITE_PERMISSIONS).apply { + replyTo = replyMessenger + data = + Bundle().apply { + putLong(NappletIpc.KEY_REQUEST_ID, id) + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + } + } + queueToBroker(msg) + } + + private fun rememberSitePermission( + origin: String, + permission: BrowserSitePermission, + decision: Decision, + ) { + val msg = + Message.obtain(null, NappletIpc.MSG_SET_SITE_PERMISSION).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + putString(NappletIpc.KEY_SITE_PERMISSION, permission.key) + putString(NappletIpc.KEY_SITE_DECISION, decision.name) + } + } + queueToBroker(msg) + } + + /** + * Chrome's permission bubble: "<host> wants to — Use your camera — Block / Allow". [answer] gets true + * (allow), false (block), or null when the prompt went away unanswered. + */ + private fun showPermissionPrompt( + origin: String, + permissions: List<BrowserSitePermission>, + answer: (Boolean?) -> Unit, + ) { + if (isFinishing || isDestroyed || permissionPrompt != null) { + answer(null) + 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 + }, + ) + } + 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. */ + private fun ensureRuntimePermissions( + allowed: Set<BrowserSitePermission>, + done: (Set<BrowserSitePermission>) -> Unit, + ) { + val needed = allowed.associateWith(::androidPermissionFor) + val missing = needed.values.filter { ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED }.distinct() + if (missing.isEmpty()) return done(allowed) + if (pendingRuntimeGrant != null) return done(allowed - needed.filterValues { it in missing }.keys) + pendingRuntimeGrant = { result -> + val granted = allowed.filter { ContextCompat.checkSelfPermission(this, needed.getValue(it)) == PackageManager.PERMISSION_GRANTED }.toSet() + if (granted.size < allowed.size) Toast.makeText(this, CommonsR.string.browser_permission_system_denied, Toast.LENGTH_LONG).show() + done(granted) + } + runtimePermissionLauncher.launch(missing.toTypedArray()) + } + + private fun androidPermissionFor(permission: BrowserSitePermission): String = + when (permission) { + BrowserSitePermission.CAMERA -> Manifest.permission.CAMERA + BrowserSitePermission.MICROPHONE -> Manifest.permission.RECORD_AUDIO + BrowserSitePermission.LOCATION -> Manifest.permission.ACCESS_COARSE_LOCATION + } + + // ---- fullscreen video ---- + + private fun enterFullscreen( + view: View, + callback: WebChromeClient.CustomViewCallback, + ) { + if (customView != null) { + callback.onCustomViewHidden() + return + } + customView = view + customViewCallback = callback + view.setBackgroundColor(Color.BLACK) + // Over the whole window, outside the inset root, so the video really covers the screen. + (window.decorView as? FrameLayout)?.addView(view, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + WindowCompat.getInsetsController(window, window.decorView).apply { + systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE + hide(WindowInsetsCompat.Type.systemBars()) + } + syncBackState() + } + + private fun exitFullscreen() { + val view = customView ?: return + customView = null + (window.decorView as? FrameLayout)?.removeView(view) + WindowCompat.getInsetsController(window, window.decorView).show(WindowInsetsCompat.Type.systemBars()) + val callback = customViewCallback + customViewCallback = null + callback?.onCustomViewHidden() + syncBackState() + } + + // ---- theme colour + Recents ---- + + /** + * Tints the system-bar areas with the page's `theme-color` (the root's padding shows through behind + * the transparent bars), and flips the bar icons to stay legible on it. Null restores the app theme. + */ + private fun applyThemeColor(color: Int?) { + val root = root ?: return + val background = color ?: resolveThemeColor(android.R.attr.colorBackground) + root.setBackgroundColor(background) + val light = ColorUtils.calculateLuminance(background) > 0.5 + WindowCompat.getInsetsController(window, root).apply { + isAppearanceLightStatusBars = light + isAppearanceLightNavigationBars = light + } + } + + /** Makes this task look like an installed app in Recents: the site's title, icon and colour. */ + @Suppress("DEPRECATION") + private fun updateTaskDescription() { + val label = pageTitle ?: title.ifBlank { null } ?: BrowserChrome.displayHost(currentUrl()) + val color = themeColor ?: 0 + val description = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ActivityManager.TaskDescription + .Builder() + .setLabel(label) + .apply { pageIcon?.let { setIcon(Icon.createWithBitmap(it)) } } + .apply { if (color != 0) setPrimaryColor(color) } + .build() + } else { + ActivityManager.TaskDescription(label, pageIcon, color) + } + runCatching { setTaskDescription(description) } + } + + // ---- long-press menu ---- + + override fun onCreateContextMenu( + menu: ContextMenu, + v: View, + menuInfo: ContextMenu.ContextMenuInfo?, + ) { + super.onCreateContextMenu(menu, v, menuInfo) + val wv = v as? WebView ?: return + val hit = wv.hitTestResult + val extra = hit.extra?.takeIf { it.isNotBlank() } ?: return + when (hit.type) { + WebView.HitTestResult.SRC_ANCHOR_TYPE -> addLinkItems(menu, extra) + WebView.HitTestResult.IMAGE_TYPE -> addImageItems(menu, extra) + WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE -> { + // An image inside a link: the hit gives the image; ask the page for the link's href. + val href = Handler(Looper.getMainLooper()).obtainMessage() + wv.requestFocusNodeHref(href) + href.data + .getString("url") + ?.takeIf { it.startsWith("http") } + ?.let { addLinkItems(menu, it) } + addImageItems(menu, extra) + } + } + } + + private fun addLinkItems( + menu: ContextMenu, + link: String, + ) { + if (menu.size() == 0) menu.setHeaderTitle(link) + if (link.startsWith("http")) { + menu.add(CommonsR.string.browser_ctx_open_new_window).setOnMenuItemClickListener { + startActivity(newWindowIntent(link)) + true + } + } + menu.add(CommonsR.string.browser_ctx_copy_link).setOnMenuItemClickListener { + BrowserWebTools.copyToClipboard(this, link) + true + } + menu.add(CommonsR.string.browser_ctx_share_link).setOnMenuItemClickListener { + BrowserWebTools.share(this, null, null, link) + true + } + } + + private fun addImageItems( + menu: ContextMenu, + image: String, + ) { + if (menu.size() == 0) menu.setHeaderTitle(image.take(80)) + if (image.startsWith("http") || image.startsWith("data:")) { + menu.add(CommonsR.string.browser_ctx_download_image).setOnMenuItemClickListener { + val wv = webView + val cookie = wv?.let { BrowserWebTools.cookieManager(it).getCookie(image) } + BrowserDownloads.download(this, image, wv?.settings?.userAgentString, null, null, cookie, if (useTor) proxyPort else -1) + true + } + } + if (image.startsWith("http")) { + menu.add(CommonsR.string.browser_ctx_copy_image_link).setOnMenuItemClickListener { + BrowserWebTools.copyToClipboard(this, image) + true + } + } + } + + /** A plain new browser window for [url], sharing this one's route, theme and account storage. */ + private fun newWindowIntent(url: String): Intent = intent(this, url, proxyPort, useTor, theme = themeType, webViewProfile = webViewProfile).addFlags(Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) + + // ---- network ---- + /** * Routes WebView traffic through the Tor SOCKS proxy when [port] > 0, else clears the override. * Process-global (this `:napplet` process hosts only sandbox WebViews) and best-effort. @@ -786,11 +1282,11 @@ class NappletBrowserActivity : ComponentActivity() { private fun setNetworkMode(newUseTor: Boolean) { useTor = newUseTor applyWebViewProxy(if (useTor) proxyPort else -1) - if (this::webView.isInitialized) webView.reload() + webView?.reload() + controlSheet?.setTor(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 liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val host = runCatching { liveUrl.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return + val host = runCatching { currentUrl().toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: return val msg = Message.obtain(null, NappletIpc.MSG_SET_WEB_TOR).apply { data = @@ -809,35 +1305,115 @@ class NappletBrowserActivity : ComponentActivity() { private fun barTitle(): String = title.ifBlank { runCatching { startUrl.toUri().host }.getOrNull() ?: getString(CommonsR.string.napplet_untitled) } /** - * The top pull-down sheet: a small grabber at the top edge (out of the corner where a site shows its - * own avatar) that expands to the Tor toggle (when Tor is available) and reload. The page can't draw - * over it. Mirrors the embedded tabs' Compose `TopControlSheet`. + * 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]. */ 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, + ), title = barTitle(), - isSandbox = false, - onReload = { if (this::webView.isInitialized) webView.reload() }, - torInitiallyOn = if (proxyPort > 0) useTor else null, - onToggleTor = { setNetworkMode(it) }, - onInfo = null, - onPermissions = { openPermissions() }, - liveUrl = startUrl, - onNavigate = { loadAddress(it) }, - onConsole = { show -> consolePanel?.setShowing(show) }, + listener = sheetListener, isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), - onFavoriteToggle = { url, isFavorite -> sendFavoriteToggle(url, isFavorite) }, ).also { controlSheet = it } + 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() + } + 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) + } + } + + override fun onNavigate(text: String) = loadAddress(text) + + override fun onTextZoom(percent: Int) { + webView?.let { BrowserWebTools.setTextZoom(it, percent) } + } + + override fun onOriginTap() = showPageInfo() + + override fun onClose() = finish() + } + + private fun setConsoleShowing(showing: Boolean) { + consoleShowing = showing + if (showing) findBar?.hide() + consolePanel?.setShowing(showing) + controlSheet?.setConsoleShowing(showing) + } + + /** Chrome's page-info sheet: connection, Tor, certificate, then site settings and clearing 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() + } + + /** Asks the main process to pin a launcher shortcut that reopens this page in Amethyst's browser. */ + private fun addToHomeScreen() { + val url = currentUrl() + val msg = + Message.obtain(null, NappletIpc.MSG_ADD_TO_HOME_SCREEN).apply { + data = + Bundle().apply { + putString(NappletIpc.KEY_FAVORITE_URL, url) + putString(NappletIpc.KEY_FAVORITE_LABEL, pageTitle ?: BrowserChrome.displayHost(url)) + } + } + queueToBroker(msg) + } + /** * Ask the broker to open the editable permission screen for the site currently displayed. NIP-07 grants * for a plain browser are keyed per visited origin (`browser:<origin>`), so we send the live origin and * the broker launches the main activity at that Connected Apps detail. */ private fun openPermissions() { - val liveUrl = if (this::webView.isInitialized) webView.url ?: startUrl else startUrl - val uri = runCatching { liveUrl.toUri() }.getOrNull() ?: return + val uri = runCatching { currentUrl().toUri() }.getOrNull() ?: return val scheme = uri.scheme?.takeIf { it.isNotBlank() } ?: return val host = uri.host?.takeIf { it.isNotBlank() } ?: return val origin = "$scheme://$host" + if (uri.port > 0) ":${uri.port}" else "" @@ -845,7 +1421,7 @@ class NappletBrowserActivity : ComponentActivity() { Message.obtain(null, NappletIpc.MSG_OPEN_PERMISSIONS).apply { data = Bundle().apply { putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) } } - if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + queueToBroker(msg) } /** @@ -856,18 +1432,18 @@ class NappletBrowserActivity : ComponentActivity() { url: String, isFavorite: Boolean, ) { - val host = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url + val label = pageTitle ?: runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url val msg = Message.obtain(null, NappletIpc.MSG_TOGGLE_WEB_FAVORITE).apply { replyTo = replyMessenger data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) - putString(NappletIpc.KEY_FAVORITE_LABEL, host) + putString(NappletIpc.KEY_FAVORITE_LABEL, label) putBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE, isFavorite) } } - if (brokerMessenger != null) sendToBroker(msg) else pendingBrokerRequests.add(msg) + queueToBroker(msg) } private fun buildConsolePanel(): View = @@ -894,6 +1470,40 @@ class NappletBrowserActivity : ComponentActivity() { addView(ProgressBar(this@NappletBrowserActivity)) } + /** Chrome's "Aw, Snap!": the page's renderer died; offer to load [url] again in a fresh WebView. */ + private fun showCrashView(url: String) { + crashView?.let { contentFrame.removeView(it) } + crashView = + LinearLayout(this) + .apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) + addView( + TextView(this@NappletBrowserActivity).apply { + text = getString(CommonsR.string.browser_renderer_gone) + setTextColor(resolveThemeColor(android.R.attr.textColorPrimary)) + textSize = 18f + gravity = Gravity.CENTER + }, + ) + addView( + Button(this@NappletBrowserActivity).apply { + text = getString(CommonsR.string.browser_renderer_gone_reload) + setOnClickListener { + crashView?.let { contentFrame.removeView(it) } + crashView = null + val wv = buildWebView() + contentFrame.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + wv.loadUrl(url) + } + }, + ) + }.also { contentFrame.addView(it) } + syncBackState() + } + /** * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by * [BrowserChromeClient.onProgressChanged]: visible while the page loads and gone at 100%. @@ -911,6 +1521,7 @@ class NappletBrowserActivity : ComponentActivity() { private fun updateLoadProgress(progress: Int) { if (progress >= 100) { topProgressBar.visibility = View.GONE + controlSheet?.setLoading(false) } else { topProgressBar.progress = progress topProgressBar.visibility = View.VISIBLE @@ -928,7 +1539,7 @@ class NappletBrowserActivity : ComponentActivity() { } private fun resolveThemeColor(attr: Int): Int { - val tv = android.util.TypedValue() + val tv = TypedValue() theme.resolveAttribute(attr, tv, true) return if (tv.resourceId != 0) ContextCompat.getColor(this, tv.resourceId) else tv.data } @@ -939,6 +1550,7 @@ class NappletBrowserActivity : ComponentActivity() { companion object { private const val TAG = "NappletBrowserActivity" + private const val ACTIVITY_CLASS = "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity" /** How often a resumed browser renews its foreground lease (well under the broker's 90s TTL). */ private const val FOREGROUND_HEARTBEAT_MS = 30_000L @@ -955,6 +1567,7 @@ class NappletBrowserActivity : ComponentActivity() { private const val EXTRA_TITLE = "title" private const val EXTRA_THEME = "theme" private const val EXTRA_IS_FAVORITE = "isFavorite" + private const val EXTRA_POPUP_TOKEN = "popupToken" fun intent( context: Context, @@ -967,7 +1580,7 @@ class NappletBrowserActivity : ComponentActivity() { webViewProfile: String? = null, ): Intent = Intent() - .setClassName(context, "com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity") + .setClassName(context, ACTIVITY_CLASS) .putExtra(EXTRA_URL, url) .putExtra(EXTRA_PROXY_PORT, proxyPort) .putExtra(EXTRA_USE_TOR, useTor) @@ -979,5 +1592,19 @@ class NappletBrowserActivity : ComponentActivity() { .putExtra(NappletHostContract.EXTRA_WEBVIEW_PROFILE, webViewProfile) // Distinct task identity per URL for documentLaunchMode=intoExisting. .setData(url.toUri()) + + /** + * Opens, as its own task, a window a page asked for; [token] names the popup WebView parked in + * [BrowserPopups]. Usable from the embedded browser's Service as well as from an Activity. + */ + fun popupIntent( + context: Context, + token: String, + ): Intent = + Intent() + .setClassName(context, ACTIVITY_CLASS) + .putExtra(EXTRA_POPUP_TOKEN, token) + .setData("amethyst-window://$token".toUri()) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_NEW_DOCUMENT or Intent.FLAG_ACTIVITY_MULTIPLE_TASK) } } 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 c4d2ba760a..01015fc67a 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserContract.kt @@ -116,6 +116,91 @@ object NappletBrowserContract { */ const val MSG_FILE_CHOOSER_RESULT = 15 + // ---- PWA-parity controls (see BrowserChrome). Client → provider unless noted. ---- + + /** Go forward in the page history. */ + const val MSG_FORWARD = 16 + + /** Stop the current load. */ + const val MSG_STOP = 17 + + /** Find [KEY_FIND_QUERY] in the page (empty clears). Provider answers with [MSG_FIND_RESULT]. */ + const val MSG_FIND = 18 + + /** Move to the next ([KEY_FIND_FORWARD] = true) or previous match. */ + const val MSG_FIND_NEXT = 19 + + /** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */ + const val MSG_FIND_RESULT = 20 + + /** Switch desktop-site mode ([KEY_ENABLED]); the page reloads. */ + const val MSG_SET_DESKTOP = 21 + + /** Set the text size to [KEY_TEXT_ZOOM] percent. */ + const val MSG_SET_TEXT_ZOOM = 22 + + /** Step back to the most recent page on [KEY_URL]'s origin (the app's home), or load it. */ + const val MSG_BACK_TO_SCOPE = 23 + + /** 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]. */ + const val MSG_PAGE_INFO_REQUEST = 25 + + /** Provider → client: [KEY_PAGE_INFO] for the page on screen. */ + const val MSG_PAGE_INFO = 26 + + /** + * Provider → client: the page opened a JS dialog. [KEY_DIALOG_ID], [KEY_DIALOG_TYPE] (`alert`, `confirm`, + * `prompt`, `beforeunload`), [KEY_URL], [KEY_DIALOG_MESSAGE], [KEY_DIALOG_DEFAULT], and + * [KEY_DIALOG_OFFER_BLOCK] when "Block dialogs from this page" should be offered. The page's JS waits + * until [MSG_JS_DIALOG_RESULT] arrives. + */ + const val MSG_JS_DIALOG = 27 + + /** The user's answer: [KEY_DIALOG_ID], [KEY_DIALOG_CONFIRMED], [KEY_DIALOG_TEXT], [KEY_DIALOG_BLOCK]. */ + const val MSG_JS_DIALOG_RESULT = 28 + + /** + * Provider → client: the page asked for camera / microphone / location. [KEY_PERMISSION_ID], + * [KEY_BROWSER_ORIGIN], [KEY_PERMISSIONS] (`BrowserSitePermission` keys). Answered with + * [MSG_PERMISSION_RESULT]. + */ + const val MSG_PERMISSION_REQUEST = 29 + + /** The granted subset: [KEY_PERMISSION_ID], [KEY_PERMISSIONS]. */ + const val MSG_PERMISSION_RESULT = 30 + + /** Provider → client: the page withdrew request [KEY_PERMISSION_ID]; drop its prompt. */ + const val MSG_PERMISSION_CANCEL = 31 + + /** Provider → client: HTML fullscreen entered or left ([KEY_ENABLED]). */ + const val MSG_FULLSCREEN = 32 + + /** Leave HTML fullscreen (the user pressed back). */ + const val MSG_EXIT_FULLSCREEN = 33 + + const val KEY_CAN_GO_FORWARD = "canGoForward" + const val KEY_FIND_QUERY = "findQuery" + const val KEY_FIND_FORWARD = "findForward" + const val KEY_FIND_ACTIVE = "findActive" + const val KEY_FIND_TOTAL = "findTotal" + const val KEY_ENABLED = "enabled" + const val KEY_TEXT_ZOOM = "textZoom" + const val KEY_PAGE_INFO = "pageInfo" + const val KEY_DIALOG_ID = "dialogId" + const val KEY_DIALOG_TYPE = "dialogType" + const val KEY_DIALOG_MESSAGE = "dialogMessage" + const val KEY_DIALOG_DEFAULT = "dialogDefault" + const val KEY_DIALOG_OFFER_BLOCK = "dialogOfferBlock" + const val KEY_DIALOG_CONFIRMED = "dialogConfirmed" + const val KEY_DIALOG_TEXT = "dialogText" + const val KEY_DIALOG_BLOCK = "dialogBlock" + const val KEY_PERMISSION_ID = "permissionId" + const val KEY_PERMISSIONS = "permissions" + const val KEY_BROWSER_ORIGIN = "browserOrigin" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 665414d757..aa5822da95 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -27,6 +27,7 @@ import android.content.Intent import android.content.ServiceConnection import android.graphics.Bitmap import android.graphics.Canvas +import android.graphics.Color import android.net.Uri import android.os.Build import android.os.Bundle @@ -36,23 +37,34 @@ import android.os.Looper import android.os.Message import android.os.Messenger import android.os.SystemClock +import android.view.View +import android.view.ViewGroup import android.webkit.ConsoleMessage +import android.webkit.GeolocationPermissions +import android.webkit.JsPromptResult +import android.webkit.JsResult +import android.webkit.PermissionRequest +import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest -import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap import androidx.core.graphics.scale import androidx.core.net.toUri import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy +import androidx.webkit.ProxyConfig +import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -61,6 +73,7 @@ import com.vitorpamplona.amethyst.commons.util.withString import com.vitorpamplona.quartz.utils.Log import kotlinx.serialization.json.JsonObject import java.io.ByteArrayOutputStream +import java.util.concurrent.Executor /** * Provider for the **embedded** in-app browser. Runs in the keyless `:napplet` process: it hosts the @@ -97,6 +110,20 @@ class NappletBrowserService : Service() { val webViewProfile: String?, ) { var webView: WebView? = null + + // The session's root view (holds the WebView, and the page's fullscreen view when it has one). + var container: FrameLayout? = null + var customView: View? = null + var customViewCallback: WebChromeClient.CustomViewCallback? = null + + // Page-originated JS dialogs and permission requests waiting on the main process's answer. + val jsDialogs = mutableMapOf<Long, JsResult>() + var jsDialogsOnPage = 0 + var jsDialogsBlocked = false + val permissionRequests = mutableMapOf<Long, (Set<BrowserSitePermission>) -> Unit>() + var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM + var desktopSite = false + var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 @@ -124,6 +151,9 @@ class NappletBrowserService : Service() { private val tabs = mutableMapOf<String, BrowserTab>() + // WebView's PermissionRequest → our relay id, so a page's cancellation can withdraw the prompt. + private val pendingWebPermissions = mutableMapOf<PermissionRequest, Long>() + // The shim never changes; read+decode it once instead of per tab on the main thread. private val shimJs: String by lazy { readContractAsset(NappletWebContract.SHIM_JS_PATH).decodeToString() } @@ -157,6 +187,7 @@ class NappletBrowserService : Service() { } tabs.values.forEach { it.fileChooser.cancel() + cancelPending(it) it.webView?.destroy() } tabs.clear() @@ -177,7 +208,7 @@ class NappletBrowserService : Service() { url = data.getString(NappletBrowserContract.KEY_URL)?.ifBlank { ABOUT_BLANK } ?: ABOUT_BLANK, proxyPort = data.getInt(NappletBrowserContract.KEY_PROXY_PORT, -1), useTor = data.getBoolean(NappletBrowserContract.KEY_USE_TOR, false), - bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, android.graphics.Color.WHITE), + bgColor = data.getInt(NappletBrowserContract.KEY_BG_COLOR, Color.WHITE), themeType = data.getString(NappletBrowserContract.KEY_THEME).orEmpty().ifBlank { "SYSTEM" }, webViewProfile = data.getString(NappletBrowserContract.KEY_WEBVIEW_PROFILE), ) @@ -189,7 +220,77 @@ class NappletBrowserService : Service() { } replyWithAdapter(tab) } - NappletBrowserContract.MSG_NAVIGATE -> tabFor(msg)?.webView?.loadUrl(normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty())) + NappletBrowserContract.MSG_NAVIGATE -> { + val tab = tabFor(msg) ?: return true + val url = normalizeUrl(msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty()) + // A renderer crash destroyed this tab's WebView; the user's retry builds a fresh one. + if (tab.webView == null) rebuildWebView(tab, url) else tab.webView?.loadUrl(url) + } + NappletBrowserContract.MSG_FORWARD -> tabFor(msg)?.webView?.let { if (it.canGoForward()) it.goForward() } + NappletBrowserContract.MSG_STOP -> tabFor(msg)?.webView?.stopLoading() + NappletBrowserContract.MSG_FIND -> { + val tab = tabFor(msg) ?: return true + val wv = tab.webView ?: return true + val query = msg.data?.getString(NappletBrowserContract.KEY_FIND_QUERY).orEmpty() + if (query.isEmpty()) { + wv.clearMatches() + wv.setFindListener(null) + } else { + wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } + wv.findAllAsync(query) + } + } + NappletBrowserContract.MSG_FIND_NEXT -> tabFor(msg)?.webView?.findNext(msg.data?.getBoolean(NappletBrowserContract.KEY_FIND_FORWARD, true) ?: true) + NappletBrowserContract.MSG_SET_DESKTOP -> { + val tab = tabFor(msg) ?: return true + tab.desktopSite = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false + tab.webView?.let { BrowserWebTools.setDesktopMode(it, tab.desktopSite) } + } + NappletBrowserContract.MSG_SET_TEXT_ZOOM -> { + val tab = tabFor(msg) ?: return true + tab.textZoom = msg.data?.getInt(NappletBrowserContract.KEY_TEXT_ZOOM, BrowserChrome.DEFAULT_TEXT_ZOOM) ?: BrowserChrome.DEFAULT_TEXT_ZOOM + tab.webView?.let { BrowserWebTools.setTextZoom(it, tab.textZoom) } + } + NappletBrowserContract.MSG_BACK_TO_SCOPE -> { + val tab = tabFor(msg) ?: return true + tab.webView?.let { BrowserWebTools.backToScope(it, msg.data?.getString(NappletBrowserContract.KEY_URL) ?: tab.url) } + } + NappletBrowserContract.MSG_CLEAR_SITE_DATA -> { + val tab = tabFor(msg) ?: return true + tab.webView?.let { wv -> wv.url?.let { BrowserWebTools.clearSiteData(this, wv, it) } } + } + NappletBrowserContract.MSG_PAGE_INFO_REQUEST -> { + val tab = tabFor(msg) ?: return true + val wv = tab.webView ?: return true + sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) { + putString(NappletBrowserContract.KEY_PAGE_INFO, BrowserWebTools.pageInfo(this@NappletBrowserService, wv, if (tab.proxyPort > 0) tab.useTor else null)) + } + } + NappletBrowserContract.MSG_JS_DIALOG_RESULT -> { + val tab = tabFor(msg) ?: return true + val data = msg.data ?: return true + val result = tab.jsDialogs.remove(data.getLong(NappletBrowserContract.KEY_DIALOG_ID)) ?: return true + if (data.getBoolean(NappletBrowserContract.KEY_DIALOG_BLOCK, false)) tab.jsDialogsBlocked = true + val confirmed = data.getBoolean(NappletBrowserContract.KEY_DIALOG_CONFIRMED, false) + when { + !confirmed -> result.cancel() + result is JsPromptResult -> result.confirm(data.getString(NappletBrowserContract.KEY_DIALOG_TEXT).orEmpty()) + else -> result.confirm() + } + } + NappletBrowserContract.MSG_PERMISSION_RESULT -> { + val tab = tabFor(msg) ?: return true + val data = msg.data ?: return true + val answer = tab.permissionRequests.remove(data.getLong(NappletBrowserContract.KEY_PERMISSION_ID)) ?: return true + answer( + data + .getStringArray(NappletBrowserContract.KEY_PERMISSIONS) + .orEmpty() + .mapNotNull(BrowserSitePermission::fromKey) + .toSet(), + ) + } + NappletBrowserContract.MSG_EXIT_FULLSCREEN -> tabFor(msg)?.let { exitFullscreen(it) } NappletBrowserContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() NappletBrowserContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } NappletBrowserContract.MSG_IME_OP -> { @@ -285,10 +386,20 @@ class NappletBrowserService : Service() { fun createBrowserWebView( context: Context, sessionId: String, + container: FrameLayout, ): WebView { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No browser tab for session $sessionId") + tab.container = container + return buildTabWebView(context, tab).also { it.loadUrl(tab.url) } + } + + /** Builds [tab]'s WebView with every client, bridge and script wired, without loading anything. */ + private fun buildTabWebView( + context: Context, + tab: BrowserTab, + ): WebView { val wv = WebView(nightThemedContext(context, tab.themeType)) // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its // profile has otherwise been used), so the storage partition must be chosen before the @@ -302,58 +413,182 @@ class NappletBrowserService : Service() { WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf("*")) { view, message, sourceOrigin, isMainFrame, replyProxy -> onBridgeMessage(tab, view, message, sourceOrigin, isMainFrame, replyProxy) } - // __nappletImeProxy: this is the EMBEDDED surface (no native keyboard), so install the IME agent - // that relays the focused field to the host's keyboard. The full-screen browser activity sets the - // direct bridge but NOT this flag (it has a real WebView window with a native keyboard). - val startScript = "if (window.top === window) { window.__nappletDirectBridge = true; window.__nappletNip07 = true; window.__nappletImeProxy = true; }\n$shimJs" - WebViewCompat.addDocumentStartJavaScript(wv, startScript, setOf("*")) + // imeProxy: this is the EMBEDDED surface (no native keyboard), so install the IME agent that relays + // the focused field to the host's keyboard. The full-screen browser activity sets the direct bridge + // but NOT this flag (it has a real WebView window with a native keyboard). + WebViewCompat.addDocumentStartJavaScript(wv, BrowserWebTools.browserStartScript(shimJs, imeProxy = true), setOf("*")) + BrowserWebTools.setTextZoom(wv, tab.textZoom) + if (tab.desktopSite) BrowserWebTools.setDesktopMode(wv, true) tab.webView = wv - wv.loadUrl(tab.url) return wv } + /** After a renderer crash: a fresh WebView in the same surface, loading [url]. */ + private fun rebuildWebView( + tab: BrowserTab, + url: String, + ) { + val container = tab.container ?: return + val wv = buildTabWebView(container.context, tab) + container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + wv.loadUrl(url) + } + /** A session closed: drop the tab and destroy its own WebView (never a sibling's). */ fun onSessionClosed(sessionId: String) { val tab = tabs.remove(sessionId) ?: return tab.bridgeReplyProxy = null // Release a picker still waiting on this surface before its WebView goes away. tab.fileChooser.cancel() + cancelPending(tab) + tab.customViewCallback?.onCustomViewHidden() + tab.customViewCallback = null + tab.customView = null + tab.container = null tab.webView?.destroy() tab.webView = null } - @Suppress("SetJavaScriptEnabled") private fun configureWebView( wv: WebView, tab: BrowserTab?, ) { - wv.settings.apply { - javaScriptEnabled = true - domStorageEnabled = true - @Suppress("DEPRECATION") - databaseEnabled = false - allowFileAccess = false - allowContentAccess = false - @Suppress("DEPRECATION") - allowFileAccessFromFileURLs = false - @Suppress("DEPRECATION") - allowUniversalAccessFromFileURLs = false - javaScriptCanOpenWindowsAutomatically = false - setSupportMultipleWindows(false) - setGeolocationEnabled(false) - mediaPlaybackRequiresUserGesture = true - builtInZoomControls = true - displayZoomControls = false - loadWithOverviewMode = true - useWideViewPort = true - mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE - if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) { - safeBrowsingEnabled = true - } - } - WebView.setWebContentsDebuggingEnabled(false) + BrowserWebTools.applyBrowserSettings(wv) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) + wv.setDownloadListener { url, userAgent, contentDisposition, mimeType, _ -> + val route = if (tab != null && tab.useTor) tab.proxyPort else -1 + BrowserDownloads.download(this, url, userAgent, contentDisposition, mimeType, BrowserWebTools.cookieManager(wv).getCookie(url), route) + } + } + + /** Answers everything [tab] still has outstanding, so no page stays blocked on a torn-down surface. */ + private fun cancelPending(tab: BrowserTab) { + tab.jsDialogs.values.forEach { it.cancel() } + tab.jsDialogs.clear() + pendingWebPermissions.values.removeAll(tab.permissionRequests.keys) + tab.permissionRequests.values.forEach { it(emptySet()) } + tab.permissionRequests.clear() + } + + private inline fun sendToClient( + tab: BrowserTab, + what: Int, + crossinline block: Bundle.() -> Unit, + ): Boolean { + val client = tab.clientMessenger ?: return false + val message = Message.obtain(null, what).apply { data = Bundle().apply(block) } + return runCatching { client.send(message) }.isSuccess + } + + private fun pushFindResult( + tab: BrowserTab, + active: Int, + total: Int, + ) { + sendToClient(tab, NappletBrowserContract.MSG_FIND_RESULT) { + putInt(NappletBrowserContract.KEY_FIND_ACTIVE, active) + putInt(NappletBrowserContract.KEY_FIND_TOTAL, total) + } + } + + private var dialogSeq = 0L + + /** + * Relays a page's JS dialog to the main process, which draws it over the tab (this provider has no + * window). Answers at once when dialogs are blocked for this page or no client can show one. + */ + private fun relayJsDialog( + tab: BrowserTab?, + type: String, + url: String?, + message: String?, + defaultValue: String?, + result: JsResult, + ): Boolean { + if (tab == null) { + result.cancel() + return true + } + if (tab.jsDialogsBlocked) { + // A blocked page may no longer hold the user on it: leaving is allowed, everything else cancels. + if (type == "beforeunload") result.confirm() else result.cancel() + return true + } + val id = ++dialogSeq + tab.jsDialogs[id] = result + tab.jsDialogsOnPage++ + val sent = + sendToClient(tab, NappletBrowserContract.MSG_JS_DIALOG) { + putLong(NappletBrowserContract.KEY_DIALOG_ID, id) + putString(NappletBrowserContract.KEY_DIALOG_TYPE, type) + putString(NappletBrowserContract.KEY_URL, url) + putString(NappletBrowserContract.KEY_DIALOG_MESSAGE, message) + putString(NappletBrowserContract.KEY_DIALOG_DEFAULT, defaultValue) + putBoolean(NappletBrowserContract.KEY_DIALOG_OFFER_BLOCK, tab.jsDialogsOnPage > 1) + } + if (!sent) tab.jsDialogs.remove(id)?.cancel() + return true + } + + private var permissionSeq = 0L + + /** Relays a camera / microphone / location request to the main process, which owns the prompt. */ + private fun relayPermissionRequest( + tab: BrowserTab?, + origin: String?, + wanted: Set<BrowserSitePermission>, + answer: (Set<BrowserSitePermission>) -> Unit, + ): Long? { + if (tab == null || origin == null || wanted.isEmpty()) { + answer(emptySet()) + return null + } + val id = ++permissionSeq + tab.permissionRequests[id] = answer + val sent = + sendToClient(tab, NappletBrowserContract.MSG_PERMISSION_REQUEST) { + putLong(NappletBrowserContract.KEY_PERMISSION_ID, id) + putString(NappletBrowserContract.KEY_BROWSER_ORIGIN, origin) + putStringArray(NappletBrowserContract.KEY_PERMISSIONS, wanted.map { it.key }.toTypedArray()) + } + if (!sent) tab.permissionRequests.remove(id)?.invoke(emptySet()) + return id + } + + private fun sitePermissionFor(resource: String): BrowserSitePermission? = + when (resource) { + PermissionRequest.RESOURCE_VIDEO_CAPTURE -> BrowserSitePermission.CAMERA + PermissionRequest.RESOURCE_AUDIO_CAPTURE -> BrowserSitePermission.MICROPHONE + else -> null + } + + /** HTML fullscreen inside the surface: the page's view covers the tab; back (from the client) leaves it. */ + private fun enterFullscreen( + tab: BrowserTab?, + view: View, + callback: WebChromeClient.CustomViewCallback, + ) { + val container = tab?.container + if (tab == null || container == null || tab.customView != null) { + callback.onCustomViewHidden() + return + } + tab.customView = view + tab.customViewCallback = callback + view.setBackgroundColor(Color.BLACK) + container.addView(view, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + sendToClient(tab, NappletBrowserContract.MSG_FULLSCREEN) { putBoolean(NappletBrowserContract.KEY_ENABLED, true) } + } + + private fun exitFullscreen(tab: BrowserTab) { + val view = tab.customView ?: return + tab.customView = null + tab.container?.removeView(view) + val callback = tab.customViewCallback + tab.customViewCallback = null + callback?.onCustomViewHidden() + sendToClient(tab, NappletBrowserContract.MSG_FULLSCREEN) { putBoolean(NappletBrowserContract.KEY_ENABLED, false) } } private inner class BrowserChromeClient( @@ -392,6 +627,91 @@ class NappletBrowserService : Service() { title: String?, ) = pushUrl(tab, view) + // This WebView is built from a Service context, so the framework can't show JS dialogs itself + // (it needs an Activity); the main process draws them over the tab instead. + override fun onJsAlert( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = relayJsDialog(tab, "alert", url, message, null, result) + + override fun onJsConfirm( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = relayJsDialog(tab, "confirm", url, message, null, result) + + override fun onJsPrompt( + view: WebView, + url: String?, + message: String?, + defaultValue: String?, + result: JsPromptResult, + ): Boolean = relayJsDialog(tab, "prompt", url, message, defaultValue, result) + + override fun onJsBeforeUnload( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = relayJsDialog(tab, "beforeunload", url, message, null, result) + + /** `_blank` / user-initiated `window.open()`: a new full-screen browser window, `opener` intact. */ + override fun onCreateWindow( + view: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message, + ): Boolean { + val tab = tab ?: return false + if (!isUserGesture) return false + val transport = resultMsg.obj as? WebView.WebViewTransport ?: return false + val (token, child) = BrowserPopups.create(this@NappletBrowserService, shimJs, tab.proxyPort, tab.useTor, tab.themeType, tab.webViewProfile) + transport.webView = child + resultMsg.sendToTarget() + runCatching { startActivity(NappletBrowserActivity.popupIntent(this@NappletBrowserService, token)) } + .onFailure { Log.w(TAG, "Could not open the new window", it) } + return true + } + + override fun onPermissionRequest(request: PermissionRequest) { + val wanted = request.resources.mapNotNull(::sitePermissionFor).toSet() + val id = + relayPermissionRequest(tab, BrowserChrome.originOf(request.origin.toString()), wanted) { granted -> + val resources = request.resources.filter { sitePermissionFor(it) in granted }.toTypedArray() + if (resources.isEmpty()) request.deny() else request.grant(resources) + } + if (id != null) pendingWebPermissions[request] = id + } + + override fun onPermissionRequestCanceled(request: PermissionRequest) { + val id = pendingWebPermissions.remove(request) ?: return + val tab = tab ?: return + tab.permissionRequests.remove(id) + sendToClient(tab, NappletBrowserContract.MSG_PERMISSION_CANCEL) { putLong(NappletBrowserContract.KEY_PERMISSION_ID, id) } + } + + override fun onGeolocationPermissionsShowPrompt( + origin: String, + callback: GeolocationPermissions.Callback, + ) { + relayPermissionRequest(tab, BrowserChrome.originOf(origin), setOf(BrowserSitePermission.LOCATION)) { granted -> + // Never let WebView remember it: the answer lives in the main-process registry. + callback.invoke(origin, BrowserSitePermission.LOCATION in granted, false) + } + } + + override fun onShowCustomView( + view: View, + callback: CustomViewCallback, + ) = enterFullscreen(tab, view, callback) + + override fun onHideCustomView() { + tab?.let { exitFullscreen(it) } + } + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { if (tab == null) return false pushConsoleLog( @@ -468,19 +788,18 @@ class NappletBrowserService : Service() { val uri = request.url val scheme = uri.scheme?.lowercase() if (scheme == "http" || scheme == "https") return false - if (request.hasGesture()) { - runCatching { startActivity(Intent(Intent.ACTION_VIEW, uri).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) } - } - return true + return BrowserWebTools.openExternal(this@NappletBrowserService, uri, request.hasGesture()) { view.loadUrl(it) } } override fun onPageStarted( view: WebView, url: String, - favicon: android.graphics.Bitmap?, + favicon: Bitmap?, ) { - // A new main-frame navigation cleared any prior error. + // A new main-frame navigation cleared any prior error, and lifts "block this page's dialogs". tab?.loadFailed = false + tab?.jsDialogsOnPage = 0 + tab?.jsDialogsBlocked = false // Re-arm favicon capture when the host changes, so a same-host in-page nav doesn't re-send. if (tab != null && OmniboxInput.hostOf(url) != tab.lastIconHost) tab.lastIconHost = null // view.title still names the page being left; the new one's arrives via onReceivedTitle. @@ -514,6 +833,35 @@ class NappletBrowserService : Service() { tab?.loadFailed = true pushLoadState(tab, view, isLoading = false) } + + /** + * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the + * whole process — every other tab included. Drop just this tab's WebView and report the load as + * failed; the tab's retry (MSG_NAVIGATE) builds a fresh WebView in the same surface. + */ + override fun onRenderProcessGone( + view: WebView, + detail: RenderProcessGoneDetail, + ): Boolean { + Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded tab" } + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + val tab = tab ?: return true + if (tab.webView === view) { + tab.webView = null + tab.customView?.let { tab.container?.removeView(it) } + tab.customView = null + tab.customViewCallback = null + cancelPending(tab) + tab.loadFailed = true + sendToClient(tab, NappletBrowserContract.MSG_LOAD_STATE) { + putBoolean(NappletBrowserContract.KEY_IS_LOADING, false) + putBoolean(NappletBrowserContract.KEY_LOAD_FAILED, true) + putString(NappletBrowserContract.KEY_URL, tab.url) + } + } + return true + } } /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ @@ -548,6 +896,7 @@ class NappletBrowserService : Service() { Bundle().apply { putString(NappletBrowserContract.KEY_URL, url) putBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, view.canGoBack()) + putBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, view.canGoForward()) title?.let { putString(NappletBrowserContract.KEY_TITLE, it) } } } @@ -568,21 +917,13 @@ class NappletBrowserService : Service() { onApplied() return } - val executor = java.util.concurrent.Executor { it.run() } + val executor = Executor { it.run() } runCatching { if (port > 0) { - val config = - androidx.webkit.ProxyConfig - .Builder() - .addProxyRule("socks5://127.0.0.1:$port") - .build() - androidx.webkit.ProxyController - .getInstance() - .setProxyOverride(config, executor) { onApplied() } + val config = ProxyConfig.Builder().addProxyRule("socks5://127.0.0.1:$port").build() + ProxyController.getInstance().setProxyOverride(config, executor) { onApplied() } } else { - androidx.webkit.ProxyController - .getInstance() - .clearProxyOverride(executor) { onApplied() } + ProxyController.getInstance().clearProxyOverride(executor) { onApplied() } } }.onFailure { Log.w(TAG, "Failed to apply WebView proxy override", it) @@ -607,6 +948,23 @@ class NappletBrowserService : Service() { val raw = message.data ?: return val envelope = parseJsonObjectOrNull(raw) ?: return + // Browser conveniences (share, blob downloads) are handled here, never brokered. The theme colour + // only matters to a window with system bars, which an embedded tab doesn't own. + when (envelope.stringOrNull("type")) { + "browser.share" -> { + BrowserWebTools.share(this, envelope.stringOrNull("title"), envelope.stringOrNull("text"), envelope.stringOrNull("url")) + return + } + "browser.download" -> { + val data = envelope.stringOrNull("data") ?: return + if (data.startsWith("data:") && data.length <= BrowserDownloads.MAX_INLINE_BYTES / 3 * 4 + 256) { + BrowserDownloads.saveDataUrl(this, data, envelope.stringOrNull("name")) + } + return + } + "browser.themeColor" -> return + } + // IME events aren't brokered — the main app hosts the keyboard. Relay the envelope to the client. if (envelope.stringOrNull("type").orEmpty().startsWith("ime.")) { val reply = diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt index 2ffb2b097e..82e0315008 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserUiAdapter.kt @@ -29,7 +29,6 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View -import android.webkit.WebView import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.privacysandbox.ui.core.SandboxedUiAdapter @@ -61,25 +60,29 @@ class NappletBrowserUiAdapter( // WebView creation must run on the main thread; openSession is called on a binder thread. mainHandler.post { runCatching { - val webView = service.createBrowserWebView(context, sessionId) + // The session's view is a container around the WebView, so HTML fullscreen can lay the + // page's custom view over it and a crashed renderer's WebView can be swapped for a new one. + val container = FrameLayout(context) + val webView = service.createBrowserWebView(context, sessionId, container) + container.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) // FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container // measures children with measureChildWithMargins, which casts to MarginLayoutParams. - webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight) - BrowserSession(sessionId, webView, service) + container.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight) + BrowserSession(sessionId, container, service) }.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } } .onFailure { t -> clientExecutor.execute { client.onSessionError(t) } } } } } -/** A single embedded browser session: the WebView is the rendered view; close tears it down. */ +/** A single embedded browser session: the WebView's container is the rendered view; close tears it down. */ @RequiresApi(Build.VERSION_CODES.R) private class BrowserSession( private val sessionId: String, - private val webView: WebView, + private val container: FrameLayout, private val service: NappletBrowserService, ) : SandboxedUiAdapter.Session { - override val view: View get() = webView + override val view: View get() = container override val signalOptions: Set<String> = emptySet() @@ -91,8 +94,8 @@ private class BrowserSession( width: Int, height: Int, ) { - webView.layoutParams = FrameLayout.LayoutParams(width, height) - webView.requestLayout() + container.layoutParams = FrameLayout.LayoutParams(width, height) + container.requestLayout() } override fun notifyZOrderChanged(isZOrderOnTop: Boolean) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt index cdc8215eb2..d1546410bf 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt @@ -32,236 +32,275 @@ 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 androidx.core.net.toUri +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 sandbox surfaces' **top pull-down sheet** — the native-View twin of the embedded - * tabs' Compose `TopControlSheet`. 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. Pull it down (or tap) to reveal the page's - * controls: route over Tor, reload, and "what it can access" (sandboxed apps). + * 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: * - * Built in code (no XML) because `:nappletHost` hosts plain Android `View`s, not Compose, and must stay - * dependency-light. Add it to a `FrameLayout` parent at `Gravity.TOP` filling the width; it manages its - * own expand/collapse. + * - 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") // plain framework Switch: :nappletHost is Compose/Material-free +@SuppressLint("UseSwitchCompatOrMaterialCode", "ViewConstructor") class NappletControlSheet( context: Context, + initialState: BrowserChrome.State, title: String, - private val isSandbox: Boolean, - private val onReload: () -> Unit, - torInitiallyOn: Boolean?, - private val onToggleTor: (Boolean) -> Unit = {}, - // When non-null, the Tor row taps through to this (e.g. a confirm dialog that relaunches) instead of - // toggling inline — used by the nSite host, where switching routing rebuilds the whole session. - private val onNetworkTap: (() -> Unit)? = null, - private val onInfo: (() -> Unit)? = null, - // When non-null, a "Manage permissions" row is added that taps through to this — used to open the - // main process's editable Connected Apps detail screen for this surface. - private val onPermissions: (() -> Unit)? = null, - // The live URL of a plain-website browser. Non-null only for the direct-WebView browser (never an - // nsite/napplet), where it renders an editable address row; [onNavigate] loads what the user types. - liveUrl: String? = null, - private val onNavigate: ((String) -> Unit)? = null, - // When non-null, a "Console" toggle row is added to the pull-down sheet. The callback is invoked with - // the new visibility each time the user flips it; the count label is updated via [updateConsoleCount]. - private val onConsole: ((Boolean) -> Unit)? = null, - // When non-null, a favorite toggle row is shown; called with the current URL and new isFavorite state. + private val listener: Listener, isFavoriteInitially: Boolean = false, - private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null, + /** 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 var title = title - private var torOn = torInitiallyOn - private var currentUrl = liveUrl - private var isFavorite = isFavoriteInitially - private var consoleShowing = false - private val panel: LinearLayout - private var titleView: TextView? = null - private var grabber: View? = null - private var torLabel: TextView? = null - private var torSwitch: Switch? = null - private var addressField: EditText? = null - private var securityGlyph: TextView? = null - private var consoleLabel: TextView? = null - private var consoleSwitch: Switch? = null - private var favoriteLabel: TextView? = null + private val grabber: View init { orientation = VERTICAL gravity = Gravity.CENTER_HORIZONTAL - - panel = buildPanel().also { addView(it) } - addView(buildGrabber().also { grabber = it }) + 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) } - private fun buildPanel(): LinearLayout = - 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)) + // ---- state updates from the host ---- - addView(titleRow()) - // Browser only: an editable address bar showing the live URL + a security glyph. nsite/napplet - // hosts pass no navigate callback, so they never get one. - onNavigate?.let { addView(addressRow(currentUrl.orEmpty(), it)) } - addView(divider()) - if (torOn != null) addView(torRow()) - addView( - actionRow("↻", context.getString(R.string.napplet_chrome_reload)) { - collapse() - onReload() - }, - ) - onInfo?.let { info -> - addView( - actionRow("ⓘ", context.getString(R.string.napplet_chrome_permissions_desc)) { - collapse() - info() - }, - ) - } - onPermissions?.let { manage -> - addView( - actionRow("⚙", context.getString(R.string.napplet_chrome_manage_permissions)) { - collapse() - manage() - }, - ) - } - onConsole?.let { - val label = - TextView(context).apply { - text = context.getString(CommonsR.string.browser_console_title_short) - setTextColor(onSurface) - textSize = 15f - setPadding(dp(8), 0, 0, 0) - // Weight 1 so the label fills and shoves the Switch to the end, like the Tor row. - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - } - consoleLabel = label - // Display-only switch (the whole row is the touch target), matching the Tor row + Compose twin. - val toggle = - Switch(context).apply { - isChecked = consoleShowing - isClickable = false - isFocusable = false - } - consoleSwitch = toggle - addView( - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(10), dp(8), dp(10)) - isClickable = true - setOnClickListener { toggleConsole() } - addView( - TextView(context).apply { - text = ">" - setTextColor(dimmed) - textSize = 18f - width = dp(28) - gravity = Gravity.CENTER - typeface = Typeface.MONOSPACE - }, - ) - addView(label) - addView(toggle) - }, - ) - } - onFavoriteToggle?.let { - val label = - TextView(context).apply { - text = context.getString(if (isFavorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) - setTextColor(onSurface) - textSize = 15f - setPadding(dp(8), 0, 0, 0) - } - favoriteLabel = label - addView( - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(10), dp(8), dp(10)) - isClickable = true - setOnClickListener { toggleFavorite() } - addView( - TextView(context).apply { - text = "★" - setTextColor(dimmed) - textSize = 18f - width = dp(28) - gravity = Gravity.CENTER - }, - ) - addView(label) - }, - ) + /** + * 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 `<title>`, 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 titleRow(): View = + private fun header(): View = LinearLayout(context).apply { orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(8), dp(8), dp(8)) - addView( - TextView(context).apply { - text = if (isSandbox) "🛡" else "🌐" - textSize = 16f - }, - ) - addView( - TextView(context) - .apply { - text = title - setTextColor(onSurface) - textSize = 16f - maxLines = 1 - ellipsize = TextUtils.TruncateAt.END - setPadding(dp(10), 0, 0, 0) - }.also { titleView = it }, - ) + 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 browser address bar: a security glyph (🧅 Tor / 🔒 https / 🌐 plain) + an editable URL field. - * Pressing Go hands the trimmed text to [onNavigate] (normalized by the caller) and collapses the sheet. - */ - private fun addressRow( - initial: String, - onNavigate: (String) -> Unit, - ): View { - val glyph = - TextView(context).apply { - text = securityGlyphFor(initial) - textSize = 15f - width = dp(28) - gravity = Gravity.CENTER - } - securityGlyph = glyph + /** 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(initial) + setText(state.url) setTextColor(onSurface) setHintTextColor(dimmed) hint = context.getString(CommonsR.string.browser_address_hint) @@ -272,208 +311,232 @@ class NappletControlSheet( 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) + layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f).apply { marginStart = dp(8) } setOnEditorActionListener { v, actionId, _ -> - if (actionId == EditorInfo.IME_ACTION_GO) { - val text = - v.text - ?.toString() - ?.trim() - .orEmpty() - if (text.isNotEmpty()) { - clearFocus() - collapse() - onNavigate(text) - } - true - } else { - false + 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 } } - addressField = field - return LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(8), dp(8), dp(8)) - addView(glyph) - addView(field) + field.post { + field.requestFocus() + context.getSystemService(InputMethodManager::class.java)?.showSoftInput(field, 0) } + return field } - /** Updates the count shown in the Console row label so the user sees how many messages are waiting. */ - fun updateConsoleCount(count: Int) { - consoleLabel?.text = - if (count > 0) { - context.getString(CommonsR.string.browser_console_title, count) - } else { - context.getString(CommonsR.string.browser_console_title_short) - } - } - - /** - * Refreshes the address bar + security glyph as the page navigates. No-op without an address row. - * Moving to another site also swaps the title for that site's host until its page title arrives - * ([updateTitle]), so the sheet never names the site the user already left. - */ - fun updateUrl(url: String) { - val previous = currentUrl - currentUrl = url - // Don't fight the user while they're editing the field. - addressField?.takeIf { !it.hasFocus() }?.setText(url) - securityGlyph?.text = securityGlyphFor(url) - if (hostOf(url) != previous?.let(::hostOf)) setTitleText(hostOf(url) ?: url) - // A different page: its pin state is unknown until the host answers through [setFavorite]. Show - // "Add" meanwhile — the toggle sends the explicit target state, so even a tap in that window can - // only ever add (idempotent), never silently remove an existing pin. - if (url != previous && onFavoriteToggle != null) applyFavorite(false) - } - - /** - * Shows the page's own `<title>`, falling back to the current host when the page has none (WebView - * reports the URL itself as the title of an untitled document). - */ - fun updateTitle(pageTitle: String?) { - val real = pageTitle?.trim()?.takeIf { it.isNotEmpty() && it != currentUrl } - setTitleText(real ?: currentUrl?.let(::hostOf) ?: return) - } - - /** Applies the registry's answer for [url]; ignored when the user has already moved to another page. */ - fun setFavorite( - url: String, - favorite: Boolean, - ) { - if (url == currentUrl) applyFavorite(favorite) - } - - private fun applyFavorite(favorite: Boolean) { - isFavorite = favorite - favoriteLabel?.text = context.getString(if (favorite) R.string.browser_favorite_remove else R.string.browser_favorite_add) - } - - private fun setTitleText(text: String) { - title = text - titleView?.text = text - grabber?.contentDescription = text - } - - private fun hostOf(url: String): String? = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } - - private fun toggleFavorite() { - val url = currentUrl?.takeIf { it.isNotBlank() } ?: return - applyFavorite(!isFavorite) - collapse() - onFavoriteToggle?.invoke(url, isFavorite) - } - - private fun securityGlyphFor(url: String): String = - when { - torOn == true -> "🧅" // 🧅 routed over Tor - url.startsWith("https://", ignoreCase = true) -> "🔒" // 🔒 secure - else -> "🌐" // 🌐 plain http - } - - private fun torRow(): View { - // Steady, muted icon (the Switch carries the on/off state) — matches the Compose twin, where the - // lock icon is a constant onSurfaceVariant tint and the Switch is the state indicator. - val icon = - ImageView(context).apply { - setImageResource(R.drawable.ic_tor) - setColorFilter(dimmed) - layoutParams = LayoutParams(dp(22), dp(22)) - } - val label = - TextView(context).apply { - text = context.getString(if (torOn == true) R.string.napplet_net_tor_label else R.string.napplet_net_open_label) - setTextColor(onSurface) - textSize = 15f - setPadding(dp(14), 0, 0, 0) - // Weight 1 so the label fills and shoves the Switch to the end, like the Compose row. - layoutParams = LayoutParams(0, LayoutParams.WRAP_CONTENT, 1f) - } - // Display-only: the whole row is the touch target (parity with the Compose row, whose Switch and - // row both route to the same onToggle), so the Switch itself doesn't take clicks. - val toggle = - Switch(context).apply { - isChecked = torOn == true - isClickable = false - isFocusable = false - } - torLabel = label - torSwitch = toggle - return LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - setPadding(dp(8), dp(10), dp(8), dp(10)) - isClickable = true - setOnClickListener { - if (onNetworkTap != null) { - collapse() - onNetworkTap.invoke() - } else { - toggleTor() - } - } - addView(icon) - addView(label) - addView(toggle) - } - } - - private fun toggleTor() { - val next = !(torOn ?: return) - torOn = next - torSwitch?.isChecked = next - torLabel?.text = context.getString(if (next) R.string.napplet_net_tor_label else R.string.napplet_net_open_label) - securityGlyph?.text = securityGlyphFor(currentUrl.orEmpty()) - onToggleTor(next) - } - - private fun toggleConsole() { - consoleShowing = !consoleShowing - consoleSwitch?.isChecked = consoleShowing - // Collapse the top sheet on toggle, like the Compose twin, so the bottom console isn't hidden behind it. - collapse() - onConsole?.invoke(consoleShowing) - } - - private fun actionRow( - glyph: String, - label: String, - onClick: () -> Unit, - ): View = + private fun iconRow(): View = LinearLayout(context).apply { orientation = HORIZONTAL gravity = Gravity.CENTER_VERTICAL - // Same vertical rhythm as the Tor row and the Compose twin's rows. + 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)) - isClickable = true - setOnClickListener { onClick() } + addView(iconView(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), dimmed, 22)) addView( TextView(context).apply { - text = glyph - setTextColor(dimmed) - textSize = 18f - width = dp(28) - gravity = Gravity.CENTER - }, - ) - addView( - TextView(context).apply { - text = label + text = consoleLabel setTextColor(onSurface) textSize = 15f - setPadding(dp(8), 0, 0, 0) + 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.toInt()) - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(1)) + 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 { @@ -489,12 +552,7 @@ class NappletControlSheet( return LinearLayout(context).apply { orientation = VERTICAL gravity = Gravity.CENTER_HORIZONTAL - // Wrap the grabber (a vertical LinearLayout defaults its children to MATCH_PARENT width, which - // would stretch this chip's background across the whole screen) and center it under the parent. - layoutParams = - LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT).apply { - 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 { @@ -541,16 +599,24 @@ class NappletControlSheet( } } - private fun expand() { + val isExpanded: Boolean get() = expanded + + fun expand() { if (expanded) return expanded = true + render() panel.visibility = View.VISIBLE } - private fun collapse() { + fun collapse() { if (!expanded) return expanded = false + if (editingAddress) { + editingAddress = false + hideKeyboard(panel) + } panel.visibility = View.GONE + panel.removeAllViews() } private fun withAlpha( @@ -565,4 +631,17 @@ class NappletControlSheet( } 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 fd5646c0a8..ca4d8ddc56 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -39,6 +39,7 @@ import android.view.KeyEvent import android.view.View import android.view.ViewGroup import android.webkit.ConsoleMessage +import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -62,6 +63,7 @@ import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.util.booleanOrNull @@ -195,6 +197,11 @@ class NappletHostActivity : ComponentActivity() { // 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 + + // Set when the renderer died and the WebView was destroyed, so teardown doesn't touch it again. + private var webViewGone = false // Set once the WebView has begun loading the shell, so a retry doesn't reload it. private var started = false @@ -204,7 +211,9 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (this@NappletHostActivity::webView.isInitialized && webView.canGoBack()) { + if (findBar?.isShowing == true) { + findBar?.hide() + } else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { webView.goBack() } else { isEnabled = false @@ -215,7 +224,8 @@ class NappletHostActivity : ComponentActivity() { /** Keep the in-WebView back gesture enabled exactly while the applet has history to pop. */ private fun syncBackState() { - if (this::webView.isInitialized) backCallback.isEnabled = webView.canGoBack() + val canGoBack = this::webView.isInitialized && !webViewGone && webView.canGoBack() + backCallback.isEnabled = canGoBack || findBar?.isShowing == true } // True between onResume and onPause. Sent to the broker (foreground hold) on connect too, in case @@ -329,6 +339,11 @@ class NappletHostActivity : ComponentActivity() { 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) @@ -378,7 +393,7 @@ class NappletHostActivity : ComponentActivity() { override fun onResume() { super.onResume() - if (this::webView.isInitialized) { + if (this::webView.isInitialized && !webViewGone) { webView.onResume() } // Launching this :napplet-process surface backgrounded the main process; tell the broker to @@ -392,7 +407,7 @@ class NappletHostActivity : ComponentActivity() { // Foreground-only: stop the applet's JS/timers in the background so it cannot fire a // sign/decrypt/pay request whose consent prompt would surface over (and be confused with) // Amethyst's own UI. Requests only happen while the user is looking at this napplet. - if (this::webView.isInitialized) { + if (this::webView.isInitialized && !webViewGone) { // webView.onPause() pauses THIS WebView's JS/DOM (the security goal — a backgrounded napplet can't // fire a sign/decrypt/pay request). Do NOT call pauseTimers(): it's process-global and freezes // EVERY WebView in `:napplet`, including the embedded browser/napplet surfaces, which never resume. @@ -465,7 +480,7 @@ class NappletHostActivity : ComponentActivity() { // A picker still up when the applet is torn down would otherwise leave its callback unanswered. pendingFileChooser.cancel() fileChooserLauncher.teardown() - if (this::webView.isInitialized) { + if (this::webView.isInitialized && !webViewGone) { // Detach before destroy(): destroying an attached WebView corrupts the shared multiprocess // renderer/network state and breaks the other (embedded) WebViews in this `:napplet` process // (dead DNS, empty DOM reads, dead selection paint, broken IME). See NappletBrowserActivity. @@ -644,6 +659,26 @@ class NappletHostActivity : ComponentActivity() { logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) } + /** + * The renderer died. Every WebView in `:napplet` shares one renderer and an unhandled crash kills + * the whole process (every embedded tab too), so drop only this WebView and offer to start over. + */ + override fun onRenderProcessGone( + view: WebView, + detail: RenderProcessGoneDetail, + ): Boolean { + Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}); offering a restart" } + webViewGone = true + findBar?.hide() + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + loadingView?.let { contentFrame.removeView(it) } + loadingView = null + contentFrame.addView(buildErrorView { recreate() }) + syncBackState() + return true + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, @@ -910,18 +945,56 @@ class NappletHostActivity : ComponentActivity() { 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, + ), title = barTitle(), - isSandbox = true, - onReload = { if (this::webView.isInitialized) webView.reload() }, - // 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. - torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, - onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null, - onInfo = { showAccessDialog() }, - onPermissions = { openPermissions() }, - onConsole = { show -> consolePanel?.setShowing(show) }, + 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() + } + BrowserChrome.Action.TOR -> setNetworkMode(!useTor) + BrowserChrome.Action.ACCESS_INFO -> showAccessDialog() + BrowserChrome.Action.SITE_SETTINGS -> openPermissions() + BrowserChrome.Action.CONSOLE -> setConsoleShowing(!consoleShowing) + else -> Unit + } + } + + override fun onTextZoom(percent: Int) { + if (this@NappletHostActivity::webView.isInitialized && !webViewGone) BrowserWebTools.setTextZoom(webView, percent) + } + + override fun onOriginTap() = showAccessDialog() + + override fun onClose() = finish() + }, ).also { controlSheet = it } + private fun setConsoleShowing(showing: Boolean) { + consoleShowing = showing + if (showing) findBar?.hide() + consolePanel?.setShowing(showing) + controlSheet?.setConsoleShowing(showing) + } + /** * Ask the broker to open this napplet's editable permission screen. The sandbox can't state its own * coordinate, so we send only the launch token; the broker resolves it to the trusted coordinate and diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 5c73f25c81..b3a69b8d13 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -37,8 +37,10 @@ import android.os.Message import android.os.Messenger import android.os.SystemClock import android.view.View +import android.view.ViewGroup import android.webkit.JsPromptResult import android.webkit.JsResult +import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -47,6 +49,7 @@ import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.core.graphics.createBitmap import androidx.core.net.toUri @@ -113,6 +116,9 @@ class NappletHostService : Service() { // createHostWebView — @Volatile gives the happens-before so the worker never sees a stale null. @Volatile var contentServer: NappletContentServer? = null var webView: WebView? = null + + // The session's root view; a WebView lost to a renderer crash is rebuilt inside it on retry. + var container: FrameLayout? = null var bridgeReplyProxy: JavaScriptReplyProxy? = null var fireSeq = 0 @@ -185,7 +191,17 @@ class NappletHostService : Service() { replyWithAdapter(tab) } NappletEmbedContract.MSG_BACK -> tabFor(msg)?.webView?.let { if (it.canGoBack()) it.goBack() } - NappletEmbedContract.MSG_RELOAD -> tabFor(msg)?.webView?.reload() + NappletEmbedContract.MSG_RELOAD -> { + val tab = tabFor(msg) ?: return true + val container = tab.container + // After a renderer crash the tab has no WebView: the retry builds a fresh one. + if (tab.webView == null && container != null) { + val wv = createHostWebView(container.context, tab.sessionId, container) + container.addView(wv, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + } else { + tab.webView?.reload() + } + } // onPause()/onResume() are per-WebView (pause/resume THIS surface's JS/DOM). Do NOT call // pauseTimers()/resumeTimers(): they are process-global and would freeze/thaw every WebView in // `:napplet` (the browser embed + other napplets), whose lifecycles are independent of this one. @@ -311,10 +327,14 @@ class NappletHostService : Service() { fun createHostWebView( context: Context, sessionId: String, + container: FrameLayout, ): WebView { // The session may have been closed between MSG_CREATE_SESSION and this posted call — fail rather // than build a WebView that no tab tracks (it would leak). val tab = tabs[sessionId] ?: error("No napplet tab for session $sessionId") + tab.container = container + // A rebuild after a renderer crash: release the previous content server first. + tab.contentServer?.close() val wv = WebView(nightThemedContext(context, tab.themeType)) // FIRST touch after construction: setProfile throws once the WebView has loaded content (or its // profile has otherwise been used), so the storage partition must be chosen before the @@ -357,6 +377,7 @@ class NappletHostService : Service() { tab.contentServer = null tab.webView?.destroy() tab.webView = null + tab.container = null } @Suppress("SetJavaScriptEnabled") @@ -533,6 +554,27 @@ class NappletHostService : Service() { pushLoadState(tab, isLoading = false) } + /** + * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the + * whole process — every other tab included. Drop just this tab's WebView and report the load as + * failed; the tab's retry (MSG_RELOAD) rebuilds it in the same surface. + */ + override fun onRenderProcessGone( + view: WebView, + detail: RenderProcessGoneDetail, + ): Boolean { + Log.w(TAG) { "Renderer gone (crashed=${detail.didCrash()}) for an embedded napplet/nsite" } + (view.parent as? ViewGroup)?.removeView(view) + view.destroy() + if (tab.webView === view) { + tab.webView = null + tab.bridgeReplyProxy = null + tab.loadFailed = true + pushLoadState(tab, isLoading = false) + } + return true + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt index 356cb58027..5bdf866d02 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostUiAdapter.kt @@ -29,7 +29,6 @@ import android.os.Bundle import android.os.Handler import android.os.Looper import android.view.View -import android.webkit.WebView import android.widget.FrameLayout import androidx.annotation.RequiresApi import androidx.privacysandbox.ui.core.SandboxedUiAdapter @@ -62,25 +61,29 @@ class NappletHostUiAdapter( // WebView creation must run on the main thread; openSession is called on a binder thread. mainHandler.post { runCatching { - val webView = service.createHostWebView(context, sessionId) + // The session's view is a container around the WebView, so a WebView lost to a renderer + // crash can be replaced by a fresh one in the same surface. + val container = FrameLayout(context) + val webView = service.createHostWebView(context, sessionId, container) + container.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) // FrameLayout.LayoutParams (a MarginLayoutParams) — the SurfaceControlViewHost container // measures children with measureChildWithMargins, which casts to MarginLayoutParams. - webView.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight) - HostSession(sessionId, webView, service) + container.layoutParams = FrameLayout.LayoutParams(initialWidth, initialHeight) + HostSession(sessionId, container, service) }.onSuccess { session -> clientExecutor.execute { client.onSessionOpened(session) } } .onFailure { t -> clientExecutor.execute { client.onSessionError(t) } } } } } -/** A single embedded napplet/nsite session: the WebView is the rendered view; close tears it down. */ +/** A single embedded napplet/nsite session: the WebView's container is the rendered view; close tears it down. */ @RequiresApi(Build.VERSION_CODES.R) private class HostSession( private val sessionId: String, - private val webView: WebView, + private val container: FrameLayout, private val service: NappletHostService, ) : SandboxedUiAdapter.Session { - override val view: View get() = webView + override val view: View get() = container override val signalOptions: Set<String> = emptySet() @@ -92,8 +95,8 @@ private class HostSession( width: Int, height: Int, ) { - webView.layoutParams = FrameLayout.LayoutParams(width, height) - webView.requestLayout() + container.layoutParams = FrameLayout.LayoutParams(width, height) + container.requestLayout() } override fun notifyZOrderChanged(isZOrderOnTop: Boolean) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt index 647c329cf8..3aeafdf70b 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletIpc.kt @@ -134,6 +134,29 @@ object NappletIpc { */ const val MSG_WEB_FAVORITE_STATE = 15 + /** + * Host → broker (browser mode): the remembered camera / microphone / location answers for + * [KEY_BROWSER_ORIGIN]. Carries [KEY_REQUEST_ID] (a long) for correlation; the broker answers `replyTo` + * with [MSG_SITE_PERMISSIONS], one `perm.<key>` string per permission holding its + * `BrowserSitePermission.Decision` name. + */ + const val MSG_QUERY_SITE_PERMISSIONS = 16 + + /** Broker → host: the reply to [MSG_QUERY_SITE_PERMISSIONS]. */ + const val MSG_SITE_PERMISSIONS = 17 + + /** + * Host → broker (browser mode): remember the user's answer ([KEY_SITE_DECISION]) for one permission + * ([KEY_SITE_PERMISSION], a `BrowserSitePermission.key`) on [KEY_BROWSER_ORIGIN]. Fire-and-forget. + */ + const val MSG_SET_SITE_PERMISSION = 18 + + /** + * Host → broker (browser mode): pin a launcher shortcut to [KEY_FAVORITE_URL] labelled + * [KEY_FAVORITE_LABEL]. The shortcut opens the page full screen in Amethyst's browser. Fire-and-forget. + */ + const val MSG_ADD_TO_HOME_SCREEN = 19 + const val KEY_REQUEST_ID = "requestId" const val KEY_PAYLOAD = "payload" @@ -161,6 +184,15 @@ object NappletIpc { /** Boolean: whether [KEY_FAVORITE_URL] is (or should become) a favorite. */ const val KEY_FAVORITE_IS_FAVORITE = "favoriteIsFavorite" + /** A `BrowserSitePermission.key` (`camera`, `microphone`, `location`). */ + const val KEY_SITE_PERMISSION = "sitePermission" + + /** A `BrowserSitePermission.Decision` name (`ASK`, `ALLOW`, `BLOCK`). */ + const val KEY_SITE_DECISION = "siteDecision" + + /** Prefix of the per-permission decision entries in a [MSG_SITE_PERMISSIONS] reply. */ + const val KEY_SITE_PERMISSION_PREFIX = "perm." + /** Boolean: this sandbox surface is now foreground (true) or backgrounded (false). */ const val KEY_FOREGROUND = "foreground" diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 2bc1944e43..2ac71b58ff 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -37,11 +37,4 @@ <string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string> <string name="napplet_console_http_error">HTTP %1$d %2$s</string> - <!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) in the browser --> - <string name="napplet_js_dialog_title">%1$s says</string> - <string name="napplet_js_dialog_title_generic">This page says</string> - <string name="napplet_js_dialog_block">Block dialogs from this page</string> - <string name="napplet_js_leave_title">Leave site?</string> - <string name="napplet_js_leave_message">Changes you made may not be saved.</string> - <string name="napplet_js_leave">Leave</string> </resources> From 810e09e9acde3a218e36452572282c4590c845cc Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 16:15:42 +0000 Subject: [PATCH 06/14] feat(browser): embedded tab chrome, site permissions and home shortcuts - TopControlSheet (Compose) drawn from BrowserChrome, find-in-page bar, Compose JS dialogs, permission prompt and page info for embedded tabs. - WebSitePermissionRegistry: per-origin camera/mic/location answers (same on Tor and open web), editable in Connected Apps detail. - Add to Home screen and favorite launcher shortcuts open the page full screen in Amethyst's browser via WebShortcutActivity, which waits for Tor so a shortcut never falls back to the open web. - 'Open full screen' opens the page on screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- amethyst/src/main/AndroidManifest.xml | 10 + .../com/vitorpamplona/amethyst/Amethyst.kt | 16 + .../amethyst/favorites/WebShortcutActivity.kt | 80 ++++ .../amethyst/favorites/WebShortcuts.kt | 122 ++++++ .../amethyst/napplet/NappletBrokerService.kt | 43 ++ .../napplet/WebSitePermissionRegistry.kt | 105 +++++ .../loggedIn/browser/EmbeddedPageDialogs.kt | 163 ++++++++ .../loggedIn/browser/EmbeddedPageRequests.kt | 43 ++ .../browser/EmbeddedWebAppController.kt | 144 ++++++- .../screen/loggedIn/browser/WebAppScreen.kt | 210 ++++++++-- .../screen/loggedIn/embed/EmbeddedFindBar.kt | 123 ++++++ .../loggedIn/embed/EmbeddedTabChrome.kt | 36 +- .../screen/loggedIn/embed/EmbeddedTabLayer.kt | 35 ++ .../ui/screen/loggedIn/embed/FindBridge.kt | 43 ++ .../screen/loggedIn/embed/TopControlSheet.kt | 368 ++++++++++++++---- .../loggedIn/favorites/NostrAppScreen.kt | 40 +- .../napplets/ConnectedAppDetailScreen.kt | 77 ++++ 17 files changed, 1515 insertions(+), 143 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FindBridge.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index c4304d3485..b120f60ad1 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -665,6 +665,16 @@ android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.Amethyst" /> + <!-- Target of web-app launcher shortcuts (Add to Home screen + favorites). Main process, so the + app and Tor are up before it hands the URL to the full-screen browser; draws nothing. --> + <activity + android:name=".favorites.WebShortcutActivity" + android:exported="false" + android:excludeFromRecents="true" + android:noHistory="true" + android:taskAffinity="" + android:theme="@android:style/Theme.Translucent.NoTitleBar" /> + <!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). --> <activity android:name=".napplet.NappletConsentActivity" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt index c3445013ca..ebe76d1a34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/Amethyst.kt @@ -23,12 +23,15 @@ package com.vitorpamplona.amethyst import android.app.Application import android.content.ComponentCallbacks2 import android.os.Build +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry import com.vitorpamplona.amethyst.service.logging.Logging import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook import com.vitorpamplona.amethyst.service.okhttp.isEmulator @@ -38,6 +41,8 @@ import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.LogLevel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.io.File @@ -145,6 +150,17 @@ class Amethyst : Application() { // Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it). FavoriteAppsRegistry.init(this) + // Mirror web-app favorites into the launcher's long-press shortcuts (open in Amethyst's browser). + CoroutineScope(Dispatchers.Default).launch { + FavoriteAppsRegistry.favorites + .map { apps -> apps.filterIsInstance<FavoriteApp.WebApp>().map { it.url to it.label } } + .distinctUntilChanged() + .collect { WebShortcuts.publishFavorites(this@Amethyst, FavoriteAppsRegistry.favorites.value) } + } + + // Hydrate the per-site camera/microphone/location answers the browser asks about. + WebSitePermissionRegistry.init(this) + // Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions). BrowserHistoryRegistry.init(this) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt new file mode 100644 index 0000000000..5650f36c6d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcutActivity.kt @@ -0,0 +1,80 @@ +/* + * 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.favorites + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.lifecycle.lifecycleScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.tor.TorType +import com.vitorpamplona.amethyst.ui.MainActivity +import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +/** + * The target of every web-app launcher shortcut ([WebShortcuts]). Runs in the **main** process, so the app + * (account, broker, Tor) is up before the page opens, then hands off to the full-screen browser through + * [FavoriteAppLauncher.launchUrl] — the same path the Browser tab uses — and finishes without drawing. + * + * On a cold start from the launcher Tor is usually still bootstrapping. Opening straight away would load a + * Tor-routed site over the open web, so when Tor is configured this waits for it; if it doesn't come up in + * time, the user lands in Amethyst (where Tor's state is visible) rather than on the open web. + */ +class WebShortcutActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val url = intent.getStringExtra(EXTRA_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") } + if (url == null) { + finish() + return + } + lifecycleScope.launch { + val app = Amethyst.instance + val torWanted = app.torPrefs.torType.value != TorType.OFF && app.torManager.activePortOrNull.value == null + val torReady = + !torWanted || + withTimeoutOrNull(TOR_WAIT_MS) { app.torManager.status.first { it is TorServiceStatus.Active } } != null + if (torReady) { + FavoriteAppLauncher.launchUrl(this@WebShortcutActivity, url) + } else { + startActivity(Intent(this@WebShortcutActivity, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } + finish() + } + } + + companion object { + private const val EXTRA_URL = "url" + private const val TOR_WAIT_MS = 30_000L + + fun intent( + context: Context, + url: String, + ): Intent = + Intent(context, WebShortcutActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .putExtra(EXTRA_URL, url) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt new file mode 100644 index 0000000000..b21d8c8755 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.kt @@ -0,0 +1,122 @@ +/* + * 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.favorites + +import android.content.Context +import android.graphics.BitmapFactory +import android.widget.Toast +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.scale +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.sha256.sha256 +import java.io.File +import com.vitorpamplona.amethyst.commons.R as CommonsR + +/** + * Launcher shortcuts for web apps — the PWA "Add to Home screen" (pinned) and the long-press list on + * Amethyst's own icon (dynamic, from the favorites). Every shortcut opens the page **full screen in + * Amethyst's own browser** through [WebShortcutActivity], never the system browser, so the user's NIP-07 + * signer is there. + * + * Main process only: the icon comes from [BrowserIconRegistry]'s captured favicon when it is a raster + * image, else Amethyst's launcher icon. + */ +object WebShortcuts { + private const val TAG = "WebShortcuts" + private const val MAX_DYNAMIC = 4 + private const val DYNAMIC_PREFIX = "fav:" + private const val ICON_PX = 192 + + /** Asks the launcher to pin a shortcut to [url] labelled [title]. */ + fun requestPin( + context: Context, + url: String, + title: String, + ) { + if (!ShortcutManagerCompat.isRequestPinShortcutSupported(context)) { + Toast.makeText(context, CommonsR.string.browser_home_shortcut_unsupported, Toast.LENGTH_SHORT).show() + return + } + val info = build(context, "web:" + idOf(url), url, title) + runCatching { ShortcutManagerCompat.requestPinShortcut(context, info, null) } + .onFailure { Log.w(TAG, "Pin shortcut request failed", it) } + } + + /** Mirrors the first few web-app favorites into Amethyst's long-press shortcut list. */ + fun publishFavorites( + context: Context, + favorites: List<FavoriteApp>, + ) { + val shortcuts = + favorites + .filterIsInstance<FavoriteApp.WebApp>() + .take(MAX_DYNAMIC) + .map { build(context, DYNAMIC_PREFIX + idOf(it.url), it.url, it.label) } + runCatching { + val stale = + ShortcutManagerCompat + .getDynamicShortcuts(context) + .map { it.id } + .filter { it.startsWith(DYNAMIC_PREFIX) && it !in shortcuts.map { s -> s.id } } + if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale) + shortcuts.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) } + }.onFailure { Log.w(TAG, "Could not publish favorite shortcuts", it) } + } + + private fun build( + context: Context, + id: String, + url: String, + title: String, + ): ShortcutInfoCompat { + val label = title.ifBlank { BrowserChrome.displayHost(url) } + return ShortcutInfoCompat + .Builder(context, id) + .setShortLabel(label.take(24)) + .setLongLabel(label.take(48)) + .setIcon(iconFor(context, url)) + .setIntent(WebShortcutActivity.intent(context, url)) + .build() + } + + private fun iconFor( + context: Context, + url: String, + ): IconCompat { + val bitmap = + BrowserIconRegistry + .iconModelFor(BrowserChrome.displayHost(url)) + ?.removePrefix("file://") + ?.let { path -> runCatching { BitmapFactory.decodeFile(File(path).absolutePath) }.getOrNull() } + return if (bitmap != null) { + IconCompat.createWithBitmap(if (bitmap.width < ICON_PX) bitmap.scale(ICON_PX, ICON_PX, filter = false) else bitmap) + } else { + IconCompat.createWithResource(context, R.mipmap.ic_launcher) + } + } + + private fun idOf(url: String): String = sha256(url.encodeToByteArray()).take(12).joinToString("") { "%02x".format(it) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index ed067f5c1a..fdf5493f62 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -33,6 +33,7 @@ import android.os.RemoteException import android.os.SystemClock import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker @@ -45,6 +46,7 @@ import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways import com.vitorpamplona.amethyst.napplethost.NappletIpc @@ -249,6 +251,47 @@ class NappletBrokerService : Service() { return true } + // The browser asks what the user already answered for a site's camera/microphone/location. + if (msg.what == NappletIpc.MSG_QUERY_SITE_PERMISSIONS) { + val replyTo = msg.replyTo ?: return true + val data = msg.data ?: return true + val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true + WebSitePermissionRegistry.init(applicationContext) + val reply = + Message.obtain(null, NappletIpc.MSG_SITE_PERMISSIONS).apply { + this.data = + Bundle().apply { + putLong(NappletIpc.KEY_REQUEST_ID, data.getLong(NappletIpc.KEY_REQUEST_ID)) + putString(NappletIpc.KEY_BROWSER_ORIGIN, origin) + BrowserSitePermission.entries.forEach { permission -> + putString(NappletIpc.KEY_SITE_PERMISSION_PREFIX + permission.key, WebSitePermissionRegistry.decision(origin, permission).name) + } + } + } + runCatching { replyTo.send(reply) } + return true + } + + // The browser relays the user's answer to a site's permission prompt; remember it per origin. + if (msg.what == NappletIpc.MSG_SET_SITE_PERMISSION) { + val data = msg.data ?: return true + val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true + val permission = BrowserSitePermission.fromKey(data.getString(NappletIpc.KEY_SITE_PERMISSION)) ?: return true + val decision = runCatching { BrowserSitePermission.Decision.valueOf(data.getString(NappletIpc.KEY_SITE_DECISION).orEmpty()) }.getOrNull() ?: return true + WebSitePermissionRegistry.init(applicationContext) + WebSitePermissionRegistry.set(origin, permission, decision) + return true + } + + // The full-screen browser's "Add to Home screen": pin a shortcut that reopens it in Amethyst. + if (msg.what == NappletIpc.MSG_ADD_TO_HOME_SCREEN) { + val data = msg.data ?: return true + val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.startsWith("https://") || it.startsWith("http://") } ?: return true + BrowserIconRegistry.init(applicationContext) + WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty()) + return true + } + // The direct-WebView browser relays its per-host Tor choice; persist it (main process only). if (msg.what == NappletIpc.MSG_SET_WEB_TOR) { val data = msg.data ?: return true diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt new file mode 100644 index 0000000000..655c527a1d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.napplet + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +private val Context.webSitePermissionDataStore by preferencesDataStore(name = "web_site_permissions") + +/** + * The user's answers to web sites' camera / microphone / location requests, per **origin** + * (`https://example.com`), the way Chrome's site settings keep them. Absent = [Decision.ASK]: the browser + * prompts the next time the site asks. The same answer holds on Tor and the open web — routing never + * changes it. + * + * Lives only in the **main process**. The keyless browser reads and writes it through the broker + * ([com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_QUERY_SITE_PERMISSIONS] / + * [com.vitorpamplona.amethyst.napplethost.NappletIpc.MSG_SET_SITE_PERMISSION]); the Connected Apps detail + * screen shows and resets it. In-memory state is authoritative for the session, written through to a + * DataStore stored under `"<origin>|<permission>"` keys. + */ +object WebSitePermissionRegistry { + private val _decisions = MutableStateFlow<Map<String, Map<BrowserSitePermission, Decision>>>(emptyMap()) + + /** origin → permission → decision (only answered permissions appear). */ + val decisions: StateFlow<Map<String, Map<BrowserSitePermission, Decision>>> = _decisions.asStateFlow() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + @Volatile private var appContext: Context? = null + + fun init(context: Context) { + if (appContext != null) return + val ctx = context.applicationContext + appContext = ctx + scope.launch { + val loaded = mutableMapOf<String, MutableMap<BrowserSitePermission, Decision>>() + ctx.webSitePermissionDataStore.data.first().asMap().forEach { (key, value) -> + val origin = key.name.substringBeforeLast('|') + val permission = BrowserSitePermission.fromKey(key.name.substringAfterLast('|')) ?: return@forEach + val decision = runCatching { Decision.valueOf(value.toString()) }.getOrNull() ?: return@forEach + loaded.getOrPut(origin) { mutableMapOf() }[permission] = decision + } + // Answers given this session (before hydration finished) win over the disk copy. + _decisions.update { current -> + (loaded.keys + current.keys).associateWith { origin -> loaded[origin].orEmpty() + current[origin].orEmpty() } + } + } + } + + fun decision( + origin: String, + permission: BrowserSitePermission, + ): Decision = _decisions.value[origin]?.get(permission) ?: Decision.ASK + + fun set( + origin: String, + permission: BrowserSitePermission, + decision: Decision, + ) { + _decisions.update { current -> + val forOrigin = current[origin].orEmpty().toMutableMap() + if (decision == Decision.ASK) forOrigin.remove(permission) else forOrigin[permission] = decision + if (forOrigin.isEmpty()) current - origin else current + (origin to forOrigin) + } + val ctx = appContext ?: return + scope.launch { + ctx.webSitePermissionDataStore.edit { prefs -> + val key = stringPreferencesKey("$origin|${permission.key}") + if (decision == Decision.ASK) prefs.remove(key) else prefs[key] = decision.name + } + } + } +} 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 new file mode 100644 index 0000000000..e7b4c6821e --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageDialogs.kt @@ -0,0 +1,163 @@ +/* + * 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: "<host> wants to — use your camera — Block / Allow". + * Dismissing it denies this once without remembering anything. + */ +@Composable +fun EmbeddedPermissionPrompt( + origin: String, + permissions: Set<BrowserSitePermission>, + 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 new file mode 100644 index 0000000000..90d9ff904b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -0,0 +1,43 @@ +/* + * 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 com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission + +/** 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 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( + val id: Long, + val origin: String, + val permissions: Set<BrowserSitePermission>, +) 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 3af22b5bbc..427b3376d5 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 @@ -35,10 +35,13 @@ import android.os.Message import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi +import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletBrowserContract @@ -48,6 +51,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent @@ -69,7 +74,8 @@ class EmbeddedWebAppController( ) : EmbeddedSurfaceController, EmbeddedImeBridge, EmbeddedMagnifierProbe, - ConsoleBridge { + ConsoleBridge, + FindBridge { private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage)) private var serviceMessenger: Messenger? = null private var bound = false @@ -106,8 +112,28 @@ class EmbeddedWebAppController( // previous view can never reap the replacement. private var sessionId: String = "browser-${SESSION_SEQ.incrementAndGet()}" - /** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack). */ - var onUrlChanged: ((String, String?, Boolean) -> Unit)? = null + /** Invoked on the main thread when the page navigates or retitles: (url, title or null, canGoBack, canGoForward). */ + var onUrlChanged: ((String, String?, Boolean, Boolean) -> Unit)? = null + + // ---- page-initiated UI, drawn by the main process (the provider has no window) ---- + + private val _findResult = mutableStateOf<FindResult?>(null) + override val findResult: State<FindResult?> = _findResult + + /** The JS dialog the page is waiting on, if any. */ + val pendingDialog = mutableStateOf<EmbeddedJsDialog?>(null) + + /** The camera / microphone / location request the page is waiting on, if any. */ + val pendingPermission = mutableStateOf<EmbeddedPermissionRequest?>(null) + + /** Page-info text for the page on screen, once requested. */ + val pageInfo = mutableStateOf<String?>(null) + + /** A main-frame load is in flight (the pill's reload button becomes stop). */ + val isLoading = mutableStateOf(false) + + /** The page is showing HTML fullscreen (a video) inside the surface. */ + val isFullscreen = mutableStateOf(false) override var onImeEvent: ((ImeEvent) -> Unit)? = null @@ -150,6 +176,9 @@ class EmbeddedWebAppController( onMagnifierFrame = null onLoadStatusChanged = null consoleLogs.clear() + pendingDialog.value = null + pendingPermission.value = null + isFullscreen.value = false } override fun teardown() = unbind() @@ -235,8 +264,9 @@ class EmbeddedWebAppController( NappletBrowserContract.MSG_URL_CHANGED -> { val url = msg.data?.getString(NappletBrowserContract.KEY_URL).orEmpty() val canGoBack = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_BACK, false) ?: false + val canGoForward = msg.data?.getBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, false) ?: false val title = msg.data?.getString(NappletBrowserContract.KEY_TITLE) - onUrlChanged?.invoke(url, title, canGoBack) + onUrlChanged?.invoke(url, title, canGoBack, canGoForward) } NappletBrowserContract.MSG_IME_EVENT -> { val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true @@ -275,6 +305,56 @@ class EmbeddedWebAppController( } } } + NappletBrowserContract.MSG_FIND_RESULT -> { + 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_JS_DIALOG -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_DIALOG_ID) + // One page, one dialog at a time; if another is somehow still up, the newcomer is refused. + if (pendingDialog.value != null) { + answerDialog(id, confirmed = false) + return true + } + pendingDialog.value = + EmbeddedJsDialog( + 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 + }, + url = data.getString(NappletBrowserContract.KEY_URL), + message = data.getString(NappletBrowserContract.KEY_DIALOG_MESSAGE).orEmpty(), + defaultValue = data.getString(NappletBrowserContract.KEY_DIALOG_DEFAULT).orEmpty(), + offerBlock = data.getBoolean(NappletBrowserContract.KEY_DIALOG_OFFER_BLOCK, false), + ) + } + NappletBrowserContract.MSG_PERMISSION_REQUEST -> { + val data = msg.data ?: return true + val id = data.getLong(NappletBrowserContract.KEY_PERMISSION_ID) + val origin = data.getString(NappletBrowserContract.KEY_BROWSER_ORIGIN) + val wanted = + data + .getStringArray(NappletBrowserContract.KEY_PERMISSIONS) + .orEmpty() + .mapNotNull(BrowserSitePermission::fromKey) + .toSet() + if (origin == null || wanted.isEmpty() || pendingPermission.value != null) { + answerPermission(id, emptySet()) + } else { + pendingPermission.value = EmbeddedPermissionRequest(id, origin, wanted) + } + } + NappletBrowserContract.MSG_PERMISSION_CANCEL -> { + val id = msg.data?.getLong(NappletBrowserContract.KEY_PERMISSION_ID) + if (pendingPermission.value?.id == id) pendingPermission.value = null + } + NappletBrowserContract.MSG_FULLSCREEN -> isFullscreen.value = msg.data?.getBoolean(NappletBrowserContract.KEY_ENABLED, false) ?: false NappletBrowserContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true val bytes = data.getByteArray(NappletBrowserContract.KEY_MAG_BYTES) ?: return true @@ -330,6 +410,7 @@ class EmbeddedWebAppController( private fun publishLoadStatus(status: EmbeddedLoadStatus) { loadStatus = status + isLoading.value = status.isLoading onLoadStatusChanged?.invoke(status) } @@ -337,6 +418,61 @@ class EmbeddedWebAppController( fun back() = send(NappletBrowserContract.MSG_BACK) {} + fun forward() = send(NappletBrowserContract.MSG_FORWARD) {} + + fun stop() = send(NappletBrowserContract.MSG_STOP) {} + + override fun find(query: String) { + if (query.isEmpty()) _findResult.value = null + send(NappletBrowserContract.MSG_FIND) { putString(NappletBrowserContract.KEY_FIND_QUERY, query) } + } + + override fun findNext(forward: Boolean) = send(NappletBrowserContract.MSG_FIND_NEXT) { putBoolean(NappletBrowserContract.KEY_FIND_FORWARD, forward) } + + fun setDesktopSite(enabled: Boolean) = send(NappletBrowserContract.MSG_SET_DESKTOP) { putBoolean(NappletBrowserContract.KEY_ENABLED, enabled) } + + fun setTextZoom(percent: Int) = send(NappletBrowserContract.MSG_SET_TEXT_ZOOM) { putInt(NappletBrowserContract.KEY_TEXT_ZOOM, percent) } + + /** Back to the app's home origin ([homeUrl]), Chrome's out-of-scope ✕. */ + fun backToScope(homeUrl: String) = send(NappletBrowserContract.MSG_BACK_TO_SCOPE) { putString(NappletBrowserContract.KEY_URL, homeUrl) } + + fun clearSiteData() = send(NappletBrowserContract.MSG_CLEAR_SITE_DATA) {} + + fun requestPageInfo() { + pageInfo.value = null + send(NappletBrowserContract.MSG_PAGE_INFO_REQUEST) {} + } + + fun exitFullscreen() = send(NappletBrowserContract.MSG_EXIT_FULLSCREEN) {} + + /** Answers the page's JS dialog [id]; [block] suppresses its further dialogs until it navigates. */ + fun answerDialog( + id: Long, + confirmed: Boolean, + text: String? = null, + block: Boolean = false, + ) { + if (pendingDialog.value?.id == id) pendingDialog.value = null + send(NappletBrowserContract.MSG_JS_DIALOG_RESULT) { + putLong(NappletBrowserContract.KEY_DIALOG_ID, id) + putBoolean(NappletBrowserContract.KEY_DIALOG_CONFIRMED, confirmed) + text?.let { putString(NappletBrowserContract.KEY_DIALOG_TEXT, it) } + putBoolean(NappletBrowserContract.KEY_DIALOG_BLOCK, block) + } + } + + /** Grants [granted] (possibly nothing) for the page's permission request [id]. */ + fun answerPermission( + id: Long, + granted: Set<BrowserSitePermission>, + ) { + if (pendingPermission.value?.id == id) pendingPermission.value = null + send(NappletBrowserContract.MSG_PERMISSION_RESULT) { + putLong(NappletBrowserContract.KEY_PERMISSION_ID, id) + putStringArray(NappletBrowserContract.KEY_PERMISSIONS, granted.map { it.key }.toTypedArray()) + } + } + fun setTor(useTor: Boolean) = send(NappletBrowserContract.MSG_SET_TOR) { putBoolean(NappletBrowserContract.KEY_USE_TOR, useTor) } override fun sendImeOp(json: String) = send(NappletBrowserContract.MSG_IME_OP) { putString(NappletBrowserContract.KEY_IME_PAYLOAD, json) } 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 4c67b7334c..4c26663a39 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 @@ -20,8 +20,13 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.browser +import android.Manifest +import android.content.pm.PackageManager import android.os.Build +import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize @@ -31,8 +36,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -42,10 +49,14 @@ 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.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst 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.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds @@ -55,12 +66,16 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.stringRes import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.favorites.WebShortcuts import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry +import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry +import com.vitorpamplona.amethyst.napplethost.BrowserWebTools import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabChrome import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost +import com.vitorpamplona.amethyst.commons.R as CommonsR /** * A **Web app** (a Nostr web client, reached by [url]) rendered as an **in-app tab** — for *any* URL, @@ -106,6 +121,10 @@ private fun EmbeddedWebAppTab( // The page's own <title>; null until the current document reports one (the sheet shows the host). var pageTitle by remember { mutableStateOf<String?>(null) } var canGoBack by remember { mutableStateOf(false) } + var canGoForward by remember { mutableStateOf(false) } + var desktopSite by remember { mutableStateOf(false) } + var textZoom by remember { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } + var showPageInfo by remember { mutableStateOf(false) } val proxyAvailable = remember { Amethyst.instance.torManager.activePortOrNull.value != null } // Start from this site's remembered Tor choice (some sites' servers reject Tor exits, so the user @@ -123,48 +142,101 @@ private fun EmbeddedWebAppTab( remember(id, EmbeddedTabHost.rebuildEpoch) { EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) } + val isLoading by controller.isLoading // Keep the URL/back callback fresh (cheap, needs the latest closure). SideEffect { - controller.onUrlChanged = { newUrl, title, back -> + controller.onUrlChanged = { newUrl, title, back, forward -> if (newUrl != "about:blank") { currentUrl = newUrl pageTitle = title } canGoBack = back + canGoForward = forward } } + fun toggleFavorite() { + val favId = "url:$currentUrl" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, pageTitle ?: hostLabel(currentUrl), System.currentTimeMillis())) + } + } + + // NIP-07 grants for a plain web client are keyed per visited origin as `browser:<origin>` (see + // NappletBrokerService.BROWSER_IDENTITY_AUTHOR); site settings jump straight to that detail screen. + fun openSiteSettings() { + browserOrigin(currentUrl)?.let { origin -> nav.nav(Route.ConnectedAppDetail("browser:$origin")) } + } + // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, pageTitle, torOn, proxyAvailable, isFavorite, controller) { + remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, controller) { EmbeddedTabChrome( title = pageTitle ?: hostLabel(currentUrl), - isSandbox = false, - onReload = { controller.reload() }, - // The page the user is looking at, not the one the tab was pinned with. - onOpenFull = { FavoriteAppLauncher.launchUrl(context, currentUrl) }, - torOn = if (proxyAvailable) torOn else null, - onToggleTor = { - torOn = !torOn - controller.setTor(torOn) - WebAppNetworkRegistry.set(url, torOn) - }, + 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, + ), isFavorite = isFavorite, - onFavorite = { - val favId = "url:$currentUrl" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) - } else { - FavoriteAppsRegistry.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + 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) + } + 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 } }, - // NIP-07 grants for a plain web client are keyed per visited origin as `browser:<origin>` - // (see NappletBrokerService.BROWSER_IDENTITY_AUTHOR); jump straight to that detail screen. - onPermissions = - browserOrigin(currentUrl)?.let { origin -> - { nav.nav(Route.ConnectedAppDetail("browser:$origin")) } - }, + 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 + }, ) } // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a @@ -182,7 +254,12 @@ private fun EmbeddedWebAppTab( } } - BackHandler(enabled = canGoBack) { controller.back() } + val isFullscreen by controller.isFullscreen + BackHandler(enabled = canGoBack && !isFullscreen) { controller.back() } + // 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) Scaffold( bottomBar = { @@ -200,6 +277,89 @@ 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. + */ +@RequiresApi(Build.VERSION_CODES.R) +@Composable +private fun EmbeddedPageUi( + controller: EmbeddedWebAppController, + currentUrl: String, + 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) } + } + + // Android's runtime permission, asked only for what the user allowed the site to use. + var runtimeRequest by remember { mutableStateOf<Pair<Long, Set<BrowserSitePermission>>?>(null) } + val runtimeLauncher = + rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + val (requestId, allowed) = runtimeRequest ?: return@rememberLauncherForActivityResult + runtimeRequest = null + val granted = allowed.filter { ContextCompat.checkSelfPermission(context, androidPermissionFor(it)) == PackageManager.PERMISSION_GRANTED }.toSet() + if (granted.size < allowed.size) Toast.makeText(context, CommonsR.string.browser_permission_system_denied, Toast.LENGTH_LONG).show() + controller.answerPermission(requestId, granted) + } + + fun grant( + requestId: Long, + allowed: Set<BrowserSitePermission>, + ) { + val missing = allowed.map(::androidPermissionFor).filter { ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED } + if (missing.isEmpty() || runtimeRequest != null) { + controller.answerPermission(requestId, if (missing.isEmpty()) allowed else emptySet()) + } else { + runtimeRequest = requestId to allowed + runtimeLauncher.launch(missing.toTypedArray()) + } + } + + val permissionRequest by controller.pendingPermission + permissionRequest?.let { request -> + val decisions = remember(request.id) { request.permissions.associateWith { WebSitePermissionRegistry.decision(request.origin, it) } } + val allowed = decisions.filterValues { it == BrowserSitePermission.Decision.ALLOW }.keys + val ask = decisions.filterValues { it == BrowserSitePermission.Decision.ASK }.keys + 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) { + 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) + } + } + } + + 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, + ) + } +} + +private fun androidPermissionFor(permission: BrowserSitePermission): String = + when (permission) { + BrowserSitePermission.CAMERA -> Manifest.permission.CAMERA + BrowserSitePermission.MICROPHONE -> Manifest.permission.RECORD_AUDIO + BrowserSitePermission.LOCATION -> Manifest.permission.ACCESS_COARSE_LOCATION + } + /** The host of [url] for the tab title, falling back to the raw string. */ internal fun hostLabel(url: String): String = runCatching { url.toUri().host }.getOrNull()?.takeIf { it.isNotBlank() } ?: url 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 new file mode 100644 index 0000000000..6d2492c951 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedFindBar.kt @@ -0,0 +1,123 @@ +/* + * 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 fc28e0cf7d..5e8817bbb4 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,30 +20,28 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome + /** - * The controls a running app surface offers through its top pull-down sheet — described as plain data so + * 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 and only the actions the surface actually supports are shown. + * 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. */ data class EmbeddedTabChrome( val title: String, - /** A sandboxed napplet/nsite (shows the shield + "what it can access"), vs a plain web client. */ - val isSandbox: Boolean, - val onReload: () -> Unit, - val onOpenFull: () -> Unit, - /** Current Tor state, or null when this surface has no Tor toggle (Tor off / locked napplet). */ - val torOn: Boolean? = null, - val onToggleTor: () -> Unit = {}, - /** The "what it can access" sheet, for sandboxed napplets/nsites; null for a plain web client. */ - val onInfo: (() -> Unit)? = null, - /** - * Opens this app's editable permission screen (the "Connected Apps" detail) so the user can change - * trust level and per-capability grants as they browse; null when the surface has no managed identity. - */ - val onPermissions: (() -> Unit)? = null, - /** Whether the current URL/app is already saved as a favorite. */ + val state: BrowserChrome.State, val isFavorite: Boolean = false, - /** Toggles the current site/app in the favorites registry; null when not applicable. */ - val onFavorite: (() -> Unit)? = null, + 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 = {}, ) 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 5865febe5b..7ca4b02c25 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 @@ -29,6 +29,7 @@ import android.graphics.BitmapFactory import android.os.Build import android.os.SystemClock import android.view.ViewGroup +import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Canvas @@ -274,11 +275,13 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { 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 consoleShowing by remember(activeId) { mutableStateOf(false) } var consoleExpanded by remember(activeId) { mutableStateOf(false) } + var findShowing by remember(activeId) { mutableStateOf(false) } if (sheetExpanded) { Box( @@ -304,6 +307,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { 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 } @@ -311,6 +317,15 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { } else { null }, + onFind = + if (findBridge != null) { + { + consoleShowing = false + findShowing = true + } + } else { + null + }, modifier = Modifier .absoluteOffset( @@ -320,6 +335,26 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) { ) } + // 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()), + ) + } + } + // Bottom console panel: opened via the "Console" row in the top pull-down sheet. if (consoleShowing && consoleBridge != null) { with(density) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FindBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FindBridge.kt new file mode 100644 index 0000000000..94fb3caf10 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/FindBridge.kt @@ -0,0 +1,43 @@ +/* + * 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.runtime.State + +/** + * A surface controller that can search its page (find in page). [EmbeddedTabLayer] draws the find bar over + * the active tab when one is open and drives the search through here. + */ +interface FindBridge { + /** The latest match count, or null before any search. */ + val findResult: State<FindResult?> + + /** Search for [query]; an empty query clears the highlights. */ + fun find(query: String) + + fun findNext(forward: Boolean) +} + +/** [active] is 0-based; [total] is 0 when nothing matched. */ +data class FindResult( + val active: Int, + val total: Int, +) 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 index 8565ce9203..892cdba6d3 100644 --- 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 @@ -25,56 +25,82 @@ 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.commons.resources.Res -import com.vitorpamplona.amethyst.commons.resources.browser_reload -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_show -import com.vitorpamplona.amethyst.commons.resources.favorite_app_add -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_open -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_tor -import com.vitorpamplona.amethyst.commons.resources.favorite_app_open_window -import com.vitorpamplona.amethyst.commons.resources.favorite_app_remove -import com.vitorpamplona.amethyst.commons.resources.napplet_manage_permissions +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 sheet** 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. Pull it down (or tap) to - * reveal the page's controls: route over Tor, reload, "what it can access" (sandboxed apps), and open - * full screen. The page can't draw over it (the surface is z-ordered below this layer). + * 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). * - * @param onConsole When non-null, a "Console (N)" row is shown so the user can toggle the log - * panel from the pull-down sheet. [consoleCount] is the number of messages already captured. + * 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( @@ -85,7 +111,19 @@ fun TopControlSheet( 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, @@ -108,75 +146,68 @@ fun TopControlSheet( shape = RoundedCornerShape(bottomStart = 16.dp, bottomEnd = 16.dp), ) { Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { - Row(Modifier.padding(horizontal = 8.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon( - if (chrome.isSandbox) MaterialSymbols.Security else MaterialSymbols.Public, - contentDescription = null, - modifier = Modifier.size(20.dp), - tint = if (chrome.isSandbox) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.width(10.dp)) - Text( - chrome.title, - style = MaterialTheme.typography.titleMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + 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() - chrome.torOn?.let { torOn -> - SheetSwitchItem( - symbol = MaterialSymbols.Lock, - label = stringRes(if (torOn) Res.string.favorite_app_network_tor else Res.string.favorite_app_network_open), - checked = torOn, - onToggle = { chrome.onToggleTor() }, - ) - } - SheetItem(MaterialSymbols.Refresh, stringRes(Res.string.browser_reload)) { - onExpandedChange(false) - chrome.onReload() - } - chrome.onInfo?.let { info -> - SheetItem(MaterialSymbols.Info, stringRes(Res.string.favorite_app_access_show)) { - onExpandedChange(false) - info() + 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) + } + } + } + } } } - chrome.onPermissions?.let { openPermissions -> - SheetItem(MaterialSymbols.Tune, stringRes(Res.string.napplet_manage_permissions)) { - onExpandedChange(false) - openPermissions() - } - } - SheetItem(MaterialSymbols.OpenInFull, stringRes(Res.string.favorite_app_open_window)) { - onExpandedChange(false) - chrome.onOpenFull() - } - chrome.onFavorite?.let { toggleFavorite -> - SheetItem( - if (chrome.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, - stringRes(if (chrome.isFavorite) Res.string.favorite_app_remove else Res.string.favorite_app_add), - ) { - onExpandedChange(false) - toggleFavorite() - } - } - onConsole?.let { showConsole -> - SheetSwitchItem( - symbol = MaterialSymbols.Code, - label = - if (consoleCount > 0) { - stringRes(CommonsR.string.browser_console_title, consoleCount) - } else { - stringRes(CommonsR.string.browser_console_title_short) - }, - checked = consoleShowing, - onToggle = { - onExpandedChange(false) - showConsole() - }, - ) - } } } } @@ -214,9 +245,152 @@ fun TopControlSheet( } } +@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( - symbol: MaterialSymbol, + action: Action, label: String, onClick: () -> Unit, ) { @@ -228,7 +402,7 @@ private fun SheetItem( .padding(horizontal = 8.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + SymbolOrDrawable(BrowserChromeLabels.symbolFor(action), BrowserChromeLabels.drawableFor(action), MaterialTheme.colorScheme.onSurfaceVariant, 22) Spacer(Modifier.width(14.dp)) Text(label, style = MaterialTheme.typography.bodyLarge) } @@ -236,7 +410,7 @@ private fun SheetItem( @Composable private fun SheetSwitchItem( - symbol: MaterialSymbol, + action: Action, label: String, checked: Boolean, onToggle: () -> Unit, @@ -251,9 +425,35 @@ private fun SheetSwitchItem( .padding(horizontal = 8.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon(symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + 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 1067fce67e..6a4a87eb83 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 @@ -54,6 +54,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds @@ -174,20 +175,37 @@ private fun EmbeddedNostrAppTab( remember(title, coordinate, isFavorite, controller) { EmbeddedTabChrome( title = title.ifBlank { coordinate }, - isSandbox = true, - onReload = { controller.reload() }, - onOpenFull = { FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr) }, - onInfo = { showAccess = true }, - onPermissions = { nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) }, + 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, + ), isFavorite = isFavorite, - onFavorite = { - val favId = "nostr:$coordinate" - if (FavoriteAppsRegistry.isFavorite(favId)) { - FavoriteAppsRegistry.remove(favId) - } else { - FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + onAction = { action -> + when (action) { + BrowserChrome.Action.RELOAD -> controller.reload() + BrowserChrome.Action.OPEN_FULL_SCREEN -> + FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr) + BrowserChrome.Action.ACCESS_INFO -> showAccess = true + BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) + BrowserChrome.Action.FAVORITE -> { + val favId = "nostr:$coordinate" + if (FavoriteAppsRegistry.isFavorite(favId)) { + FavoriteAppsRegistry.remove(favId) + } else { + FavoriteAppsRegistry.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + } + } + 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/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index ed86de0cb2..fa9308a6be 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -37,6 +37,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -62,6 +64,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer @@ -114,6 +117,7 @@ import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.napplet.NappletBrokerService +import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry import com.vitorpamplona.amethyst.napplet.counterpartyLabel import com.vitorpamplona.amethyst.napplet.descriptionRes import com.vitorpamplona.amethyst.napplet.labelRes @@ -321,6 +325,11 @@ fun ConnectedAppDetailScreen( } } + // Camera / microphone / location answers for a website (`browser:<origin>`), editable here. + if (coordinate.startsWith(BROWSER_PREFIX)) { + SitePermissionsSection(coordinate.removePrefix(BROWSER_PREFIX)) + } + // Recent activity (NIP-46 clients only) if (nip46Client != null && nip46Activity.isNotEmpty()) { SectionHeader(stringRes(Res.string.nip46_signer_activity_title)) @@ -341,6 +350,11 @@ fun ConnectedAppDetailScreen( signerLedger.revokeAll(coordinate) } capabilityLedger.revokeAll(identity) + // A forgotten website also loses its camera / microphone / location answers. + if (coordinate.startsWith(BROWSER_PREFIX)) { + val origin = coordinate.removePrefix(BROWSER_PREFIX) + BrowserSitePermission.entries.forEach { WebSitePermissionRegistry.set(origin, it, BrowserSitePermission.Decision.ASK) } + } // Forgetting an app has to stop it signing *now*. The two ledgers above only // drop persisted + capability grants; the broker separately holds the signer's // in-memory "allow for this session" grants, which would otherwise keep the @@ -534,6 +548,69 @@ private fun AppIdentityHeader(state: ConnectedAppDetailState) { } } +private const val BROWSER_PREFIX = "browser:" + +/** + * The site's camera / microphone / location answers ([WebSitePermissionRegistry]), each switchable between + * Ask, Allow and Block — Chrome's per-site permission list. + */ +@Composable +private fun SitePermissionsSection(origin: String) { + val all by WebSitePermissionRegistry.decisions.collectAsStateWithLifecycle() + val decisions = all[origin].orEmpty() + SectionHeader(stringRes(CommonsR.string.browser_permission_section)) + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(4.dp)) { + BrowserSitePermission.entries.forEachIndexed { index, permission -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(horizontal = 12.dp)) + val decision = decisions[permission] ?: BrowserSitePermission.Decision.ASK + var menuOpen by remember { mutableStateOf(false) } + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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 + }, + ), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + Box { + TextButton(onClick = { menuOpen = true }) { Text(stringRes(decision.labelRes())) } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + BrowserSitePermission.Decision.entries.forEach { option -> + DropdownMenuItem( + text = { Text(stringRes(option.labelRes())) }, + onClick = { + menuOpen = false + WebSitePermissionRegistry.set(origin, permission, option) + }, + ) + } + } + } + } + } + } + } +} + +private fun BrowserSitePermission.Decision.labelRes(): Int = + when (this) { + BrowserSitePermission.Decision.ASK -> CommonsR.string.browser_permission_ask + BrowserSitePermission.Decision.ALLOW -> CommonsR.string.browser_permission_allowed + BrowserSitePermission.Decision.BLOCK -> CommonsR.string.browser_permission_blocked + } + @Composable private fun SectionHeader(text: String) { Text( From 40d06f4152b3203b85543fd3c05c50f546f9c889 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 16:15:45 +0000 Subject: [PATCH 07/14] docs(plans): record browser PWA-parity implementation Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-pwa-parity.md | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/amethyst/plans/2026-09-26-browser-pwa-parity.md b/amethyst/plans/2026-09-26-browser-pwa-parity.md index 0321a63f50..115e1678a3 100644 --- a/amethyst/plans/2026-09-26-browser-pwa-parity.md +++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md @@ -1,6 +1,6 @@ # Browser surfaces → PWA parity review -Status: **review / proposal**, decisions recorded in §6. Scope: every surface that renders a +Status: **implemented** (phases 0–5, see §7), decisions recorded in §6. Not yet verified on a device. Scope: every surface that renders a plain web client — the **embedded** bottom-bar tab and the **external** full-screen browser — plus their shared top pull-down "pill" and bottom console "pill". The nsite/napplet hosts reuse the same chrome and are covered where the change is shared. @@ -287,3 +287,71 @@ nsite/napplet hosts (sandbox profile permitting). 4. **Add to Home screen:** the shortcut opens the page full screen in **Amethyst's browser** (not the system browser), so the signer is present. It launches through a main-process trampoline. + +## 7. Implementation (2026-09-26) + +What shipped, where it lives, and what was deliberately left out. + +**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 +`BrowserChromeTest`. `nappletHost/…/BrowserChromeLabels.kt` maps each action to one Material Symbol and +one label (shared strings in `commons` Android resources). Both renderers draw from these two: + +- `TopControlSheet` (Compose, embedded tabs). +- `NappletControlSheet` (plain Views, full screen). It loads the same Material Symbols font from the + `commonsUI` assets, so the icons match. Three glyphs were added to the subset font: `FormatSize`, + `DesktopWindows` and `AddToHomeScreen`. + +**Top pill.** Header: security icon, page title, and `host · connection`. Tapping it opens page info; +long-pressing copies the link. The full-screen header also has ✕. Below it: +- The icon row: back · forward · reload/stop · star (filled when pinned) · share. +- Menu rows: back to app, copy link, edit address, find in page, text size, desktop site, add to Home + screen, open in another browser, open full screen. +- Privacy: Tor, what it can access, site settings. +- Developer: console. + +**Bottom pill.** Console as before; find in page docks in the same slot, one panel at a time. + +**Web platform** (`BrowserWebTools`, `BrowserDownloads`, `BrowserPopups`, `BrowserExtrasScript`, +`BrowserJsDialogs`; used by both `NappletBrowserActivity` and `NappletBrowserService`): +- JS dialogs: native in full screen; relayed to Compose for the embed. +- `_blank` / `window.open` open as a new full-screen window, with `opener` kept through a parked popup + WebView. +- `intent:` URIs are parsed, hardened, and fall back to `browser_fallback_url`. +- Downloads follow the page's route (Tor through SOCKS, remote DNS), carry the page's cookies, and land + in Downloads. `blob:` and `data:` downloads come through the page script. +- HTML fullscreen: the whole window in full screen; inside the surface for the embed. +- `navigator.share` polyfill (text/url). +- Renderer-crash recovery in all four WebView owners. + +**OS integration.** +- `theme-color` tints the full-screen window's system-bar areas. +- `setTaskDescription` sets the title, favicon and colour in Recents. +- Add to Home screen (`WebShortcuts` + `WebShortcutActivity`, main process, waits for Tor). +- Dynamic launcher shortcuts for the first four web favorites. + +**Permissions and page info.** +- Camera, microphone and location go through a per-origin prompt. Answers are kept in + `WebSitePermissionRegistry` (main process, same on Tor and open web), then Android's runtime permission + is requested. +- The Connected Apps detail screen lists and resets those answers. +- Page info shows the connection, Tor and certificate, with Clear site data (this profile only). + +**Left out, and why.** +- **Pull-to-refresh:** WebView exposes no overscroll signal, so on pages that scroll an inner element + (most SPAs) `scrollY == 0` is always true. A pull there would reload mid-scroll. +- **Long-press menu in the embedded tab:** the SurfaceControlViewHost surface doesn't deliver long-press + context menus. It is available in the full-screen browser. +- **Theme colour in the embedded tab:** the tab owns no system bars. The script's message is ignored + there. +- **Find / text size for embedded nsites and napplets:** that host has no IPC for them yet. The rows are + hidden via `BrowserChrome.State.hasFind` / `hasTextSize`. The full-screen nsite/napplet host has both. +- **Notifications / Push, file sharing through Web Share:** unchanged, see §4.5. + +**Needs on-device verification.** +- Popup `opener` handoff across activities. +- Renderer-crash recovery. Trigger it with `chrome://crash` in a debug build, or by killing the renderer. +- Downloads over Tor. +- The pinned-shortcut cold start with Tor enabled. + From 7267d6c359b61c2b2e5336025b8b1725ee7494b9 Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 17:33:13 +0000 Subject: [PATCH 08/14] design(browser): UI review and Compose prototypes of the redesigned pill Review of the shipped browser chrome (menu hierarchy, mute states, bare inputs, stock dialogs, Views/Compose parity) and a redesign: navigation capsule, page-action tiles, privacy card with plain-language consequences, a stateful collapsed handle, a first-class address editor with suggestions and paste-and-go, a floating find pill, a filterable console sheet, a three-choice permission prompt, origin-badged page dialogs, and a page-info sheet with in-place permission choices and a confirmed clear. Stateless composables in commonsUI (browser/ui/pill) with previews; BrowserPillRenderTest renders every screen offscreen in both themes to commonsUI/build/browser-pill/. Adds Cookie, NorthWest and ContentPasteGo glyphs to the Material Symbols subset. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-ui-review.md | 236 ++++++++ .../font/material_symbols_outlined.ttf | Bin 498440 -> 504348 bytes .../composeResources/values/strings.xml | 100 ++++ .../commons/browser/ui/pill/AddressEditor.kt | 219 ++++++++ .../commons/browser/ui/pill/BrowserPill.kt | 503 ++++++++++++++++++ .../browser/ui/pill/BrowserPillModel.kt | 90 ++++ .../browser/ui/pill/BrowserPillPreviews.kt | 240 +++++++++ .../commons/browser/ui/pill/ConsoleSheet.kt | 231 ++++++++ .../commons/browser/ui/pill/FindInPagePill.kt | 135 +++++ .../commons/browser/ui/pill/PageSheets.kt | 476 +++++++++++++++++ .../commons/browser/ui/pill/PillComponents.kt | 348 ++++++++++++ .../commons/browser/ui/pill/PillIcons.kt | 215 ++++++++ .../commons/icons/symbols/MaterialSymbols.kt | 3 + .../browser/ui/pill/BrowserPillRenderTest.kt | 96 ++++ 14 files changed, 2892 insertions(+) create mode 100644 amethyst/plans/2026-09-26-browser-ui-review.md create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md new file mode 100644 index 0000000000..58d0284e59 --- /dev/null +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -0,0 +1,236 @@ +# Browser surfaces: UI review and redesign + +Status: **review + Compose prototypes**. The prototypes 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). + +Follows `2026-09-26-browser-pwa-parity.md`. That plan settled *what* the browser does. This one is about +how it looks and feels. + +## 1. What's wrong with what shipped + +Reviewed against the code as merged: `TopControlSheet`, `NappletControlSheet`, `BottomConsoleSheet`, +`NappletConsolePanel`, `BrowserFindBar`, `EmbeddedFindBar`, `EmbeddedPageDialogs`, `BrowserJsDialogs` +and the full-screen permission / page-info `AlertDialog`s. + +**Top pill** +1. **It's a settings list, not a menu.** Up to 15 rows of equal weight in one scrolling column. Share, + copy and find (used daily) look exactly like desktop site and open-in-another-browser (used rarely). + Chrome solves this with an icon strip plus a short list; we copied the list and made it longer. +2. **No visual hierarchy.** Section labels are 12sp caps you don't notice. Every row is the same 44dp + icon + label. Nothing groups. +3. **Security state is a word, not a signal.** "Not secure" and "Onion-routed over Tor" are the same + grey 13sp text. Plain HTTP isn't warned about, and Tor, the thing Amethyst does that Chrome doesn't, + isn't celebrated. +4. **Three rows are just switches** (Tor, desktop site, console). Each is a full-width row whose only + content is the switch. They say what they are, not what they mean ("Loads over Tor" vs "the site + can't see your IP"). +5. **The text-size stepper** is squeezed into a list row: two 24dp buttons and a percentage, with no + preview and no way back to 100%. +6. **The collapsed grabber is mute.** It never shows loading, an insecure page, or console errors, so + there's nothing to invite the pull. + +**Inputs** + +7. **Edit address is a bare text field** dropped into the header. It has no clear button, no go button, + no suggestions (the launcher's omnibox has them), and no paste-and-go. +8. **Find in page** is a full-width sheet with a default `TextField`. The count is loose text, and "no + matches" has no state of its own. The native version draws a different bar. +9. **The JS prompt field** has no label. "Block dialogs from this page" is a third button squeezed in + beside Cancel/OK. Chrome uses a checkbox, because it's an option, not an answer. + +**Dialogs and sheets** + +10. **The permission prompt** is a stock `AlertDialog` with bullet text and only Allow / Block. There's + no "just this time", no icon for what's being asked, and nothing about the connection. +11. **Page info** is a paragraph in an `AlertDialog` with three crowded buttons. The site's permissions + aren't there, and Clear site data runs immediately, with no confirmation. +12. **The console** is a flat list with no filters (errors are buried in logs), no copy, and a count + that only shows inside the menu. + +**Parity** + +13. **Two renderers, two looks.** The full-screen pill is plain Views: framework `Switch`, framework + ripple, hand-rolled type sizes. Sharing the layout spec stopped the two drifting in content, but + they still don't look alike. `:nappletHost` already depends on `:commonsUI` (Compose), so the + full-screen window can host the same Compose pill in a `ComposeView`. + +## 2. Principles + +- **One tap for daily things, two for rare ones.** Navigation, star and share sit in a capsule. + Page actions are a grid of tiles. Privacy and developer settings are grouped cards below. +- **State is visible, even collapsed.** The security colour (error for HTTP, Tor accent for onion), + loading progress and console errors show on the grabber itself. +- **Inputs are first-class.** Every text input has: + - a real container and a leading icon that says what it is; + - a placeholder or label; + - clear and submit buttons; + - the right keyboard and IME action; + - an empty or error state. + + Choices are segmented buttons or switches that state their consequence, never a bare "on". +- **Explain consequences in plain words.** Say "The site can't see your IP address", not "Loads over + Tor". Say "Allow while visiting / Only this time / Don't allow", not "Allow / Block". +- **One implementation.** Stateless composables in `commonsUI`, driven by `BrowserChrome` (already + shared). The embedded tab, the full-screen window (through `ComposeView`) and a future desktop browser + all draw the same pixels. + +## 3. The redesign + +### 3.1 Collapsed handle + +A 48×24dp capsule at the top centre holding a 32dp bar. +- A 2dp progress line runs under the bar while the page loads. +- The bar takes the error colour on HTTP and the Tor accent when onion-routed. +- A 6dp error dot appears at the right edge when the console has errors. +- It keeps its current gestures: pull or tap to open. + +### 3.2 Expanded pill + +``` +╭────────────────────────────────────────────╮ +│ [P] Primal ( ✕ ) │ monogram tile · title · close (full screen) +│ ╭────────────────────────────────────────╮ │ +│ │ 🧅 primal.net ✎ │ │ origin field: tap edits the address +│ ╰────────────────────────────────────────╯ │ +│ ⓘ You left primal.net [ Back to app ] │ only when out of scope +│ ╭────────────────────────────────────────╮ │ +│ │ ← → ↻ ★ ⇪ │ │ navigation capsule +│ ╰────────────────────────────────────────╯ │ +│ ╭────╮ ╭────╮ ╭────╮ ╭────╮ │ +│ │ ⧉ │ │ 🔍 │ │ Aa │ │ ⌂+ │ │ page actions (tiles, 4 per row) +│ │Copy│ │Find│ │Text│ │Home│ │ +│ ╰────╯ ╰────╯ ╰────╯ ╰────╯ │ +│ ╭────╮ ╭────╮ ╭────╮ │ toggle tiles fill when on (Desktop) +│ │ 🖥 │ │ ⬈ │ │ ⛶ │ │ +│ ╰────╯ ╰────╯ ╰────╯ │ +│ A ━━━━━━●━━━━━━━ A 115% Reset │ appears when "Text" is on +│ ╭ Privacy ───────────────────────────────╮ │ +│ │ (🧅) Onion routing [ ● ] │ │ +│ │ The site can't see your IP │ │ +│ │ (⚙) Site settings › │ │ +│ │ Camera allowed · Location blocked │ │ +│ ╰────────────────────────────────────────╯ │ +│ >_ Console (3 errors) [ ] │ +╰────────────────────────────────────────────╯ +``` + +- **Origin field.** It looks like an input on purpose (the address is one tap away without an address + bar taking space): + - a pill-shaped container on `surfaceContainerHighest`; + - the security icon in its colour; + - the host in `titleSmall`; + - a trailing pencil. + + Tapping it opens the address editor (§3.3). Long-pressing copies the link. +- **Navigation capsule.** Five 48dp icon buttons on `surfaceContainerHigh`, `CircleShape`. Back and + forward dim when they can't be used. The star fills in `primary` when the page is pinned. Reload + becomes Stop, with a progress ring, while loading. +- **Tiles.** 72dp-tall rounded (16dp) cards on `surfaceContainer`, each with an icon and a two-line + `labelMedium` label. Toggle tiles (desktop site, text size open) switch to `secondaryContainer` with + a check badge. +- **Privacy card.** Grouped rows on one rounded container: + - each row has a leading icon in a 36dp tinted circle, a title, and a supporting line that states the + consequence; + - a trailing switch, or a chevron for navigation; + - site settings summarise camera / mic / location decisions inline. +- **Developer row.** The console with an error badge (`errorContainer` pill) and a switch. It's the + only developer item, so it gets no heading. +- **Sandboxed apps** use the same frame: + - the origin field reads "Sandboxed app" with a shield and doesn't edit; + - the capsule is reload + star; + - the tiles are find and text size; + - "What it can access" joins the privacy card. + +### 3.3 Address editor (first-class input) + +Replaces the origin field inside the pill: +- A 56dp pill field with the security/search icon leading, the URL selected, and trailing clear (✕) + and Go (a filled `primary` circle with an arrow). The keyboard is URI type with IME Go. +- Below it: + - a **Paste and go** chip when the clipboard holds a URL; + - then up to five suggestions (favorites first, then history, from `OmniboxSuggestions`), each with a + monogram, the title and the URL; + - each suggestion has a trailing ↖ that fills its URL into the field without going. +- Back or tapping outside collapses it to the origin field. + +### 3.4 Bottom: find in page and console + +- **Find pill.** A floating 56dp capsule, 12dp above the bottom edge, with shadow. It holds: + - a search icon; + - the field with placeholder "Find in page"; + - a match chip ("3 / 12", tonal; "No matches" in `errorContainer`); + - up/down, a divider, then close. + + The IME Search action jumps to the next match. +- **Console sheet.** A drag handle, then a title with filter chips "All 42 · Errors 3 · Warnings 5", + then Copy and Clear. Log rows have: + - a 3dp level-coloured stripe; + - the message in monospace; + - `file:line` muted on the right; + - long-press to copy. + + Errors show first when the Errors chip is on. + +### 3.5 Permission prompt + +A card sheet: +- the origin field (read-only) on top; +- 48dp tinted icon circles for each thing asked (camera / mic / location); +- a title in plain words ("Use your camera and microphone?") and one body line; +- when the site is on Tor and asks for camera or mic, a muted note: "Calls can reveal your IP address + even over Tor". + +Three stacked full-width buttons: **Allow while visiting** (filled, remembered), **Only this time** +(tonal, not remembered), **Don't allow** (text, remembered). + +### 3.6 JS dialogs + +A card with: +- the origin field as its header (so a page can't spoof Amethyst UI); +- the message in `bodyLarge`, scrolling past 40% of the screen; +- for `prompt`, an `OutlinedTextField` with a label, a clear button, autofocus, and IME Done that + submits; +- a **"Don't let this page show more dialogs"** checkbox (second dialog onward); +- right-aligned Cancel / OK. "Leave site?" uses a destructive-tinted Leave. + +### 3.7 Page info + +A sheet with: +- **Header:** monogram, host, and the full origin. +- **Connection:** rows with icons: encryption state (a warning tone for HTTP), then the route (Tor or + open web, with the same consequence line as the pill), then the certificate: issued to, issued by, + and expiry. +- **Permissions:** one row per camera / mic / location with a segmented button **Ask · Allow · + Block**, editable in place. +- **Cookies and site data:** an outlined destructive button, "Clear site data". The first tap turns it + into an inline confirmation ("Sign out of this site and delete its data? · Cancel · Clear") instead + of acting at once. + +## 4. Tokens + +| Use | Token | +|---|---| +| Pill surface | `surface`, 0 tonal elevation, 6dp shadow, 24dp bottom corners | +| Inputs, origin field | `surfaceContainerHighest`, `CircleShape` | +| Navigation capsule | `surfaceContainerHigh`, `CircleShape` | +| Tiles, grouped cards | `surfaceContainer`, 16dp | +| Toggle "on" | `secondaryContainer` / `onSecondaryContainer` | +| Insecure | `error` / `errorContainer` | +| Tor accent | `tertiary` (no new colour, readable in both themes) | +| Spacing | 4dp grid, 16dp sheet padding, 8dp between tiles | +| Type | title `titleMedium`, origin `titleSmall`, tiles `labelMedium`, supporting `bodySmall` | + +## 5. Next steps + +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. diff --git a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index 3af0f83bd12753dca85367876b4f9743284cd835..05533f6459d8955444431ba52cdce4a0eacc3b6d 100644 GIT binary patch delta 9235 zcmbt(3w%_?_5Ycfd+)wCyUD(?Np?4zCn1o8JP3~vK;Ewe5VfFScnC#a0!5(O-6W_~ zttdfhQ7Qe4;)D8VpgxM?tChdSN)ah4LZT27vV_fMv%A^n@7xUup|wB#{Qnc?&fJ+Z zGiT16Ip6PHw*T4k%mqs>5CG8Pg9FXvx%0;Fz4QO90qEHXK=x0$W!xO{0%QS%Oi$!m z9H9v)`*F>tn)RS;dmSJw#rNtZt83Ps%qTqrz-IzT^6$d_N4i$Oiv71?ziWBThILqK zaMo2=N|&$Na#!=mbH4;gxDVj-vCC1u^_Iw60FenG96G&XS<TY&?OPV22B|1twgMX@ z-$~NJ0pw<E$Xu~{<K{&>7BCbD0I>X3YnRmg^H0Jq0A;oKzPe`fI`T3@waI3bx2>sJ zy=?xh+G7Ccj$prX-P#QsAHQ(VRe)w}GyWlN0vh;o`m@V7FG?8QEv4*z90x65?5K#B z-yZy@ST;-`Bgm7DzaryE<GWNz$gzfF?C!<_jw1>aF181)D1kkVWB4J2J$CuvPH9%d z4xxy4e%JWEFz#B@EsoCbJPJ>-XQAg<_r2X)1Ah*<W3hBwdRDs4HZ|Rzo^1O7AYCgi zXl=D#u%+2XS!Y;3wY_5fz-qH5TE|#RtS?)4Sq;{kEuEI9Est0qvfOPwY$>+90$@35 zIc`BqTE<&8Sk|PymxdNk+l}w}Y0stxQ`e;KN_8}fQ>T_=2P&PKocdtu!ql;;3sP@Q z`DJQ0KuShR20pV=%0#)8q!g_=lu~b=ZZ?}!%*V~tJj=YrJl33%{8sXV0H%@2g#gJ0 z(`nN$jC+kyag1rNaj$8zaVOT6iSNd<#xIR6#_h(rrh}$u9VWf$4dY2;3U=#_lT9m4 zcNo4ld|_N=e9}1Gc;4`~q1AY&aWsJ8d!yNyY@~)avHlBVhT(H#mSMBOV>Ied8P*%d z>Fe}S{Q`r<FiF>{>(r0bKc}Cr+o4U;e5PHm{UTAWSt2g3wV|JVuT4tm(IhAQE#Wbk z44S9V8`TMSCOm{)Z3*E7X+n+qBlUd=$@nfL+>tO*{So?-OmVMdm2`jHJH(l;CGsd9 z_2Xn$L%Jfr;dR2zA$Rrmki~r>C~$jk9?)_NxeeQ{<?HQYjZBL_r^KI=NOeQH+>%X7 z;;+NRXKx~TWa#xe4$C9?4Syg8N=!H+O%*r(H345-y+XOd@gaPSZRqF&(tB7JvzXrL zE&88$Z?B~n`zlyglJR5?UcbBX3XCF?$OL$TXo;Grh!TDee}M1d9Gr({GMwB5Ps1`O zh2gLSo(Jz;#0KBMW%!mz;5%r7)9@vH1!Lhj`~#99AMhFx3Vz5VTj3~7fkt=(0?<wF zBfo-T!pKJQAQZu9vJJKn6ETpLa2QhIDwzpxvI@7Qh1u{&c%`9G`H5qd`KZ~GGA`v8 zDIcc<QfH^`NIjdTPMe!{f7+q6FDypOO_m*&k1Szpj&+swch+WGl5K(QH@5$4bEOYS zuTKA6`X_eIo?~BZciK;9$TH?;I5Li9bY%|CoRhgT^PS94RzcQ=ti4$+*@@X(vj3W+ z${CsS+nh7G^K$p+_6#Z;wA(SLJuf$JM&84DujaW1YX&bE{F}ky{8{<W=AR!jXvl3t zULWEbI&SEFLz@cn3#J#`UhqJ{(*>srNnuIh{KAI9KNWsc7%Ivt8dY>}(eI1?I;?2e z%3-I9)x}ea*B5_M!b%R6jxD{v^tICO$|Pm^Wt+<mmt7t{bNJiCKO25-_|<YK*Ooi% z<;CR_$`_TtQQlM@9HALeJYw32nh{$@JU-&J5nqjDBlAa28@XxZ!I9@iNk@$vwQ1Db zqcx-FkFFd2X$7koTv1U`RdG+na}}>wyjyXy!Zjvs%&alaF;9(Y9CM=5Ryn(JcjcMN z$k@cO?~SvJyLsG}ac_(}GhRKuZ2W`c58jl2)30xO(=kChp<u$?37aN7GQm4>)Wl~d zUYvCIq*o?=Jy|<>#N>x3e=x;9rE1E-DPK=DPOYB$=+rl-c23hytC_ZIx_$bp>4&B_ z&oIrXoH2jKlQTY=sh_!E=HZ!vS(&po&3b#*r7Eb(tQuK0xoSz(rm82Z_E&vg^?g;e zT2Y-|U0Z!(_D!>&pWQykVVW~z&SP_q%v~_|m79m({QNxCyy|)H+@iW==`D}m;=i@v z)<<uB=QitY^KSd{XIVcxJYO~c4-4cAoC^*u2;821`@Y+~cU0W5@{XemVc~rXKVM{D zv~<x|cNX8d@6L-g_L@6tnir2+{J`RWEwL<Fw0s}%;)S9C#0drh$Uq4R0F5gXic1^} zHp6DNj{O#-P=L1pvT}xJlosa<H|AuN78Vv|=+jFxu+(BM)@RtWOSL6s#l>Z1#ls9n z%z9f{Nw!_k8R&`fO25Cdd|z&Lb#z_zv19241?dMAW3zKB6)&V0L|-x*?MCC~5tY?r z%lEn6n>R;JR2QUcXkmIm#*iTy1?gh9-PridO=%T_2`apg*=PW@f-IuV(5CCt32luQ zR+Go00dis=If2FF)zQMnx11xA90F8BHE!b`yluHqf;V<HV1UcA52d9gW2mUCV4=eW zgVA8n>(rDDD=RI@&aex5ok237&JU<#`|#}S9Pyu;qFfT1pqv%qGyab>&E2`Ga^#Ag zx9am9Mww;w+<fy*;|Zx;wR1;uMn*E`;nzx%rZpP1n3r#TS2k>feC8Te^_JO#{_;RI z3eTPX_&pOjen@qxE@9Lvsz_4Id17@XX^PK)vmEQI<i@AKY4*KXXP8l-STmE3saZel zfxnOm4v`T3y9NILc0X`?Kf1foz1#bL+fJjqp=e5D#@RfEbY<3gJCo9#w1MuTPd2Xm zPR46BOV+O0xNObFMeAxdY+SZz`P#-UmjZ%(&D!-FS1h`B*@lgc>pE6am9S*(+Pi<g zOc5^{Pr3if8xRahEQZdGC8*<$0c2yttG-d>$%f0m*^VU``<KJd;ci$BYvCT)0Kb5H zVGH~c?nnMs3-#~-JOsanN8mTG0~+8-*bTo!#CSH2BF`aW9DtYL6%6UGArLfTuzwTY zhQGkO2o4{>NAOql<4@z@aRUAY5k#OwqC;3P5i?08miZ)|WRh%>O9qo6q<|EWVp58r zB!X52sU#B+x~7upWG1N=;fx?ClSO1PSxQ!rm1GrJL)MY?h&h|cX0lbpAmSu-WE**q zY;Q;m6g5l?WRhKtD+5|8TS0b-)Id_$xGne?alC@Id1EM$Lna_^odWWgmw~*07m$xe z137jU$nnR4{8J6&i)VoR>jfZRwF3Fp3FQ1FAm85!<l>7!E?)rBF&2o&2E_XyvfDjC zf?l+EK2Wk9DE~fCX(~{~TR>I61*+W(G-(r1<HO*f$)|v(l>oH_fZ9(3&D;qzFB|BP z6+nkA23o2CIs%19eGGJLBGB>EfKHkNbP7&11Ldo5^0}`9op&7Q{5?SLm<4p%Q$SbZ zV(apNu0H~F%SoX3zXx<XzW>Gov;pOJ<2rjkbpU-11rKZl`jQjqAIAcH4JT++13ioy zzq1wSyJvwOvjhEX70^>CcLwFZMFr0XfL_c5+WG>}E7;bl0~-7gXzWp7)B=og!1xQm zBzENPxxmz?fhGMJnBE7>ybf5Z37FNP1ZKMoEb~5KS$lxx-HaasyMPVFNpRP!A`jT4 zp}?ko4Q$qfz^ZZZZ6&}Kd<bm07uecqz&4=lrXaAbD}epd4eb7{!0O)t_R#CV9?l2$ z#CBl6rI<T_{q8KVJ%0oC^dVr+>;dL@b{nw0cLDqT3}E|k(&tgb7vBQ*(m#N`d=l6} z+`+46V6V*q_WHxX-awf~T<~xJ*jr11y@SdhIRoryCa@3j{o`Z6K0*0UalX%R%n4Nb z)P7)RFn^88o_hgU^JBoiM+my`CvdQf*m21T>~c2dmw~n8Ze5AMuG9f@w*vFd0_Mj_ z10BGE24JDFz|lv6<Ie&o-v?YmCUA)-flGQBxC|+9gPgz(T?|~|Xy8iU2X43vxY0Of z4Av(bfSbA>xVd)VZVdxBf4>8`#h(JVY$<TdQRwH`xY7;W1}rzD-~+9|p$WOiaPo%R zfqRMrZqJ{Ad$t_7{d0gjum-p{UI*^b8Q|VS*$)E1eX<p}Pmcrl`FP+?nt(fv`HT&? za~}iO{3>v+Cg3_Z0_Q6OuE&8Ihh_j5c@MbQHsCo6@WL73@tW}3bHJOFz^6HZw@m{+ z2ipd}1AM_#z!$y@{IEH|mt<r95cskd;D?_Cegu{y&jLTjfEmlNlQ91Y_;JU9pS%?K z8HK>lT@U=v-U5C}BJhr7&j7#TQQ+5_fWPM$@U?2-9~=$*juXJ|#>MyHg8NSa|0+&) z$Oilo9q{j=LdWF5|9uzmU%UwX=@r1A$M)~A{mORWJ+A^EodN=90wJLe1pN^ZGB$yb zGZuvWg&-7gAdFlJLgjj>5+;Q~n7R*y*>(`-VdFwM2#XhkutE*O-P=LfSOUTp>~rh^ zVVe|$hfw~J_d$3vAB3m>2Esm+eSR|tFTDuDA76Oj7&&~5Dh}YiDuGG(S-aH6!2%n= ze48BB+9*`n2&}WoV6{zv8k-dRdAQA{z>?Y10XaSLdO~^1^OPZtzn1zdPMbu<t0d49 zpiyapG%-=Clrso*cLh2kp-_bJJWH3v_>dz6J#aN32}l#AfsT$2m&=9E07L^mUmyxL z>y1nGt<$@__-0Ka-9k4>$`XWNPzZK3Vd@B8^%15_5)y4TLYqA;wdO9qi&tJyLQ9Jm zl&0+L>_NG?gRod_QmWHZ`xYS*DiL?nzYUI3>P+Edg)tu7tpTaVrZJf`IxPpmj&^r6 zOu1B3qLSkx0jbpO7W>?-trt**3$3m0%dH*h80iO?YPYSD>KtlyYK|!<RSmk_Gp>AB zSG~N;m7t&y_PTYdI;WyD+}i57;5nfirgPP@E|*UAFjeVv;FM;LF`LI^!Z*k3B(A38 z$4g3Vx_XPwW;WY$OEBfy%8NA6@sCfM&Ky4;_W8n`T+Y=iv%qX_X^DLU-+a^Lsw0lv z+&boJvgztl;$uYN<L@3kc<@~;&op^ETAG?#I=uDCh;&pu<^iW1KXpb-Mrx$gINBTH zl}6>cbIKMM@JVJbM*ri@&9zDTPQCYnS9wn9wcE3Da<c4pFU!ohW(vo(3FC8#(~HO0 zo3QG%M}pB{1UN26!aZR>rk-#xgb*R&Br+LcEEbJsL`ak|WN<Pr!i7;l?2<|4aw!%P z4hY9{B*T7#?faM6ZO-T94oWz?AB2bi{*Fk5u?UlEFv*yVC$XSE;&;1a5Cf`_sA|(B zQAsqY4uVQ0K+t>T%9VDPs~rn(um_XXa^pe+{3q^6oHOm;_7n~}Lb3RkWRx))wcNO$ zFo)Za%jNit@8v(Pfw&jU@hwq886{S4OcIvFylvqy$Avi^CQdHIVgYa1<%(}4Nv5ea zOC!=qkG2Oi7*BgT+uB+#UAolP*4Y#A2mJr_uJs?0+UL4>^`grMo(rk!X0@zdCR6(Y zF_0-de)L-f(`w~9x!v=vM_tQgYLEZd)bH_#cVK`Y^`67@{z-8w9HBu7)*6GIjKm_r z2<}@(A`%{Ap&*L|0`*iSmDZ+&xDb=D2zH5$7$7i21OhSk&}Rqeo1YzEA4VN&jAUx` z3pL<xN*KwIo?t0bSs7*lvlKIrZ3@gx;6>Fa;ky)i1?S>*5ueMa;?=>XsM5wad8tkC z@E4_;R@TJZA|`hr1*qEPs-@xZxou8r=eSy`(|PK7H%8I3$9YFdCURE|I3+68`SU8( z5L>;Z>5Hze^z^Q-ZM-Dp3q|F;kMeRp$oDV}3A%ZW5Od>JZ|w7PI?{vcNMf$A45&}7 zu0=mF;gYS9NG<hv<n_2lBqEhYBHe*{iB!Q@Aix;e_U6Mca9`|oB*O}f>PhgdO#+W$ zWOra>--Y%#jFJ65w8tPYAh|bUsfIlKjb#v+u++gGjQG1S;y;HG|9On~wP>eJHimtA z%n2x`!7>rE3bPW9?wlC=W%T{{Ut4S>Yh;>VO@P0bz?aC}$CBZ&wf^W6<D*BSM`HJq z@X(2{50AT&RDc`m*u7K2E#qQKqetqChanZ*tsTuJVQ^pr(m*f4k<KEmPG?Hi;++rr zJ6l^iqpr)IU=(6eLi=*W=L>~ngi~tNc!d}Y(<wp9{O#@SSFZX3-gZ|Ym|;ye==ulX zK)Jv{H0tl}ia4ZlVDj+LA;95!xO_hph;IWlV;pw%`Z9JnGce{~^27v*RF!1ZrP$JK zHd`t>Tri%c5MTnwYK3Sx7{uVFX$peEXtCMSj9QIW#i1XWgB_x*LwtxI@j;+WV4Oq% zybyzO@dKfMvA9uq+@T8f8PFQn(3vAwX*9YdStt^TL_=P$&+UddQUU7;M1tW6;q)m< zQYlz8Dx?rB)YaA1>2~`ffgs@n$-p71Nu?NgVzrW>zek#+flyH)CA*&(%ybUib#GFO ztKE=*)T?=!B-Ya%=yJ7nkN_cd$$jSnx+J|;BI7w(f}{sQ-cOu7At03rwK;@J<Xn&2 zg@GJx(|42@wggV1P$cQ}X|^QryU3`K;J`tcu5TKZ5rYdh;Nyn!hJ(W~&JkvFsyTtV zzHe`BI&-$e-`e6vj09dT=l{JlCF>JST8-JN3AVJf_+TUw^^m)zrM0ZR-Rrx0?wq?V zG5J4-^-dGhY7$e83CU@L1}B5Bh0o1_-GwbokB~#Mu4|4eF=sYfs;w&~l^RpbT7@h@ zpP)AwjRvh0np<2~JG$JisK#hCMswO+U7$`j8w|Qcb%HiYtI`;fWP<-{n~``>>T27i z?_41s!bf)|Fm!vhM#+&_ID|nmf;Uj3R!caCT1ospAxb%kRIaW+x|5HfGlrve=w=)* z2uu`=M1aZU5<w;vcn%>JN8^lAsWd4?&yh$t7zy;m1yw33uQ#cp;TX?zyiBbUN5_14 z%>sg2$~dW@(IqB{jqUBeSfVvm=SWJF%LT?q+r>G3XxN~}YSw5?iTY%dT$F5X?hNU& zGfg^qqEV$#2Ajo3s!Ya|2pyhocV|cI_dc;PB}L82V(ymqD_w4{-^Zua4=d`sBG*ma zTkG>!hv>N64Hw2)7(@$sy@4p@QDvn{O5_R!Qa~Vx{ns+#DS>D(&=U$FXczecK?ub- zg+d`mcjgsHLAtNHz1{utI4%?=zzG7v1VR`sEQl-OzyrVLsJ$-Sr&#<##eKS8^=z3M z$%sl42}i;`-G28~x7*{3#3F==S>&D3NHiLa)e7-=Nx`m`Q3!UsQO+9(^h8ksoFXob zBg8w+9JE#*6NiPP7+dgHK8(4j$aS*Yd#^+t2mUc$K(Ri}n6ucA7VGj89vPWZCAnrW zaiSQe{xT+h8T8o=N_;^;%EOCBLuXUSITpFviAK9VpmzciilQNTK|;eJpk<}l+PAbg zHBtpJ3g4h0DvK5rrQ%l}D{LsQK%hvFLL?FjheG~t7ZSGHy|J(e4C2M;Eu$gILo^bO zM(Y^mI4IR=70~0kg7FDILp(lTG(tdgUA}i5kHa{QBItzibRs|p=Ut1At2v5Dt`YUT zo*2aH)#08H;FZ85QN{RJtS%`=Vgx*0o!y~eBogcn@oII<i9LWfvd$I@1)`injr;`H zLCKg}O>nJ1pv&WNwRJ%(JByIrMJ+7J!SJ`R{v;Y9i!cE%GvXPN(@;cIG87Dk!cqx( zE7~5y_!=Sjy~H3K6rCDp4vP<TZ;IdTjKiZ9Bs@XnAxEE3#t*=E<w^ighSm}i6fYoR zOQh{WXUq`|BLnvP{P8eDq*|?38tVz;270>OonBE?rQ#!3S}wb<ba;I|p&-FXu`@wD zvhLOkSN%N!G=DT2zv*bx*5)f1g1WnWUQZ7d8$~ON{2%o}rp9<8HU<KrP?s;p#bOS0 zARy6z*h*AsX=(EAw$6aRv-|SJOP9q)t2KeOp8o3MrS{IN{$PN!ijC0_az|HNw^D)M z41z2nBsO+;2P18lLkbCx(N7{ox{+gvU&pJFLoG)RRfQaCA#$h(kVB2ZOD=vLFE`l) zBveu)SUk)|vNQ(&!vH@#evr*L(|?@3*na|BK|=jw<iu%>^AX4YLP&mqgQ$8rNFN2k z4?r;x^e3$Lu@0y4zp)I{KvtpcqZM&hVYU1Ri})eVAnD@_{R~0>!wg|?9{hAV;2d-v z{vAJt|9O3Q{}bTenGzB~JdWb38(>_-@dK%C2;HGx?Ct|^M=yF4gzp3RS$xgN*Dc*! z8wd|h`%eI>jGqEc={8Cbi7@?lFtYt9`q+LHZjkl-S9n1tbp4O<&g`EEDLIHDKM6zz zi81lIJ@MrR+*sPbI!2+~AHs?sgo@ms3=b*h>z<B?@Iwf|L<H!M_?rKS_|BAg9FGU` t$iPT0#`5TaR58YrYXN*<1jiW72?yeVYZOQxS~`)(EWEjNqGbQP{{c*m{x<*s delta 3386 zcmYjU3tWs@8~>m8yzg6x={hr}X=b{rluD)hRrfNwOG2$A328&y`V4j#OTW;x7HhHA z($|KzvEf^{#iq3``?dJm+zGj?CCe|dd}msFdFS_>=REi4ocH|C%xvsZEpJh|f&j2! z0}u1~jI8KSSAI4Oc(w%~TNARQ(#cjh1HCb8TvfW>GUj`{U|vBv%<eV;&;6L3I%Q@- z+5e6fGA}_1m{=Dv`^qOXcQO0>%>H0n!P~|-bCz`-<EGO}-zl>Ea8&>x$plWFoW}g8 zr=Bwd`pO_~SzA1{pfGUx<Z{*l%ZE|LOkr}v#2z{j767N>nH7~2znCzXIrIY5sdV;~ zf>*Og{Ro(O8|9e=m1Sf*Wet%^=C7GmFmr0Ic5xeUNyF@BWwYO|F#Gvf2XNH`kd^dr z0oQwe&A`N4lO>@~O*wjm#o_i(tHS#E<=R_Du8$!>WQ}1Vi6Z)^oVETu_e_79mV6$< zlR?Z(>ew!tpw18{coO}5G0Sj9WS;XyCw+jakFIA>k8jVUp0=m+pH}s@^geq1+FogI zZ?DuO*sJVqG=~9uwjw~?t?tq&H9_hW_4k?`>ceWa+EN{+_Ep!bYt=UD4Ao=RC#sd| z_f#{~U#Wam4S?!L)k#$g<58;FsuH_{cBO#bS|+>NZB#y2&Qj`?&1~}kWgs(HGd@(g zM477$RgP0;*j6bW09yxJ<LGE>e50_nu$3tKY;}rcg+gJgXj2eHiej!JRG}W)GIR+b z4<5=kZm6ZaU0xyEB>UYMBi|_7B#)ETve^`)EW0Q>C%YwEF3XVD%h$`Tbn>0D|H^Ec z*+LdCFOrX!c1nMe&5+f}l4KX9`=xhelVrhw^oC3>vynl{)+4Qusimi7_R{&%hcYW^ zgw0}`mo~Xlg*4i_+xn4Bkj-YBMC(-+R_3QI=2)Dy6wUh!Td+eTxoIJhJU5q1S|v5? z<gupABL++ICCiwpThb>nmE;dOI%I)F&gAius3C0g&4fX7O-fC-^dAP}0^U4iDSNXk zNR8fS;Bb8t5z<Ni9~P4Lrwen){)7IZNhRq__lEw~8)qb@e~j)QBS{iVt#TnF`bD;{ ze?G*Cxc)t7VNS$F{~3{T#F9nW4etHZ$oQi-?V0M1;3$(@|6v0TFv&>9XWt*}ud(sX zto|<z5JAF;mZYMD_>%w<O(L<HNQfC3L<V9Lw%`UX;|i`3UlM^0D1<)(@D{eAhbRlt ziEdmX0<PmK&f^?95Q!gf8bjd$GrVRPc?x$j4~LL|W;Ed$o{&mX1z$o)8Ce1!gpx(5 zBvN8cim(St{6Z4(kQCztERcpqH0V>!PUz+<8WhiMJ#81*HYo=v<CS&FR=YuV{&uBy zEA7tOy->xg-c{{UT~#}&)70zLKWMBpv6{u2ZJJy5ruJ#}I{O3mR~-}%fetke-#QL* z3~*fJ_@(1br-4pMPUTJpr#sHWowJ=cIiGa#aru|acP>4{QirW}9qgLt`jzWLx3O-Y z>)fun^X}2^Mef_&+lE^Wj~%|pL-fe<_}JsRr@3db=QhvhBm73J8`0%Oy{x^$yfVD1 zz4m)O@V4|$_Ac?(dmFqjdq45<@)_szFQ2<39Y$7<Z1sh&yRXjo8^2P&UVnf8Y5p7h z5BqnG@*XvF)YbqYU{1gX0c!(x1ndj=KA<x|cR%1|pe%5D;JU!(!1I9*f@qL!(8!>) zpm&452s#t=AXpx(4PF%75PUO49x@@MIpk%iQ)p@E7on#^uZBJjvkHq1OAnhIRuNVc zb~x-&xI=hgcvg6M_|fnu5kV1E5t}3SMCL~xiF_7i8<i1N71b2g5$zhSjb0FaI{LTK z^3fr>(W#^N#~`L4rXkilwk&pY?1k7@abx3_#@&gxj-M01IsQ_DO+s42f`pcY3yCg? z;}h>B`6RuQv_0u^GM}tY&P`sP+?k?DsY*GRa(7I~n1y2+$9y;D?wHqFGp(yOM4PRh zuC3G7YmaGv)ZQK&H}<1c)6|mGrqs)67HJu2i_;4<>1F9JGfFbrGi@?;neAC_S!r1Z zvVO}>%hqLgjZ=)<Fz#MXRZjc(qVZ>QV{_lhJ(zoc!qf@-^SC^(y!Z3In;12*ZsMIu zZj&}m>YY4wa>L|1`40Kp3s8_$u(9CgTTVq&4GScReuk_VWtid9#er6aRcpNy3<s}x zQ1%^g@45puSl=2T7)IRd6%9U*r*np#PYw$<1myK=NZ2hxFa)k(_bDmYFL@q9>hy=6 zr|I5ea4AADN>GYfC__0aFb|ce!U8PBA}qoCSc>IXfojyC7Ij#Qbqr)1`+>KWfowbK z(ZEo!i(#jk;iH8?`acZbhhT*MafXub`|)-b7x03C)|^-}BuR;!D2QD!(GUlAkqjel zWH|98Uc`r?*og8V5<(&w)?-OLNhB#o{F8K&NhXj<B%c(LB7JM0O5fe*qnEvKA*&7H zFD$qLIb^l*zBJJoa(=HNy2f0P-7z40*rHo5fP7O6a;O;OxGBhq10bh@LC&;+w6}wt zw+88G1nDdXxl#>s?HtH$bCA2GAit!5{Mroiq!r|u2Ba?s<mDc4cmR$jfD^mGnH~pc zb`so>t>7$Mz}WygPM!}=;RH^_fm0s>=bR4CwGNzT4LI*maDL2r)FW^q;o!pNfs0xQ zZZu1j!2FX~a&0HL)Lw8|t>AL1z~vtWH<cBekqvHUH@J#d;41HddymP>lfcz5|5{dO z{WEZzx^&>SZUMKg5nO#KxCWMBS1`CH*74Wd!R<E%cT5YeZ5_C?%<m_bxPvu#ku`iR z72M5saNSJzAQIfuhv545f%}~mdF2gC%t1LVs7WU1Kn`@s4NwaQP@9dQvKUZBAgEF& z1y$FBYFa^^7l95t0_wI8l&zWuW`joMg2sxViOrxXEI1<{H2Wdw6b)#}JkVLpx7-DE zZXM`+C1~Y#(1o`_ms|q<AP2O152$_wXq^J|BU45_plh#ze%uPWt_f7Pz7}*tCFmzC z=@!=E=Ut%N`apM>f!0TZ{<{jafffJq9_UV%tcewFt_J<u5_B&>_osj!VDh0ypx-k8 z;}xJMSj@>?pl42jwlg|c4%%@B^kNI><!;a`ouF5l{(3!hv`Y)x)ed@#t@d^>=$!`8 z`)unTvei9iNuNkTe+vhFS`5k#6mR_SZQuu_fH&&{Ke!#doi}*Ldho6*z`GZM_v!{e z(i;4zV(@`%9u*FL^eOOh72vfSz^D0v&oBp{bxH?5`v~~S%vi{bbM}Fs`x1Or6?mNo z_{HbIFI@`$LsRgp9l+~ZqE8or-%=00f%)!i18+zI-?9<>H<sWJvSQydI`#<s$vW_- z7lQ9-0)IIM{0$L&R|ohzO7M4Uz~9dV|EumH_#T$*c`^9kcR`?+APh)_V5WdzRSZG4 z0)lN3`;}e=LFE8J{S1Pp7J@?y1jnrqoEdj%gy41?f;;0LQbtQ4cy5H?>j#1TdK4m} zA;d0+puGVhtrS9L9)#R(2ou*q(3M_+P;Lg{-7OH<p%JQC!PVO!Y+%VYKZ3BM3qk{H z$j}L4Zy|)kl@N{wLO99vXPExy90*qyLbxY~@GDFDA{`>BhA4@HXk`vjJ_4fR97GpC zh{K~Hjywg?pZ#YD?SUBOhX^sT6QXt`#EdHt$K^oG=OGp{<BSrBvpXQZ%>w4HhPdb@ z#ARzCRx{r<2@pT3gt&g*vPMewHd1cTj%6!%b9-0rrUzc_P2SC)*|cm<p1?@lJ15WN H%dGzc+gh4i diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index b0fc2cb0e0..d1e8fac7c4 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5351,4 +5351,104 @@ <string name="cyberspace_bag_unsigned">Unsigned, so this author is a claim</string> <string name="cyberspace_bag_unknown_kind">An item of kind %1$d, which this client does not draw</string> <string name="cyberspace_bag_opaque">%1$d bytes of something this client does not read</string> + <!-- Browser pill (redesign prototypes, see amethyst/plans/2026-09-26-browser-ui-review.md) --> + <string name="browser_pill_close">Close</string> + <string name="browser_pill_tile_copy">Copy link</string> + <string name="browser_pill_tile_find">Find</string> + <string name="browser_pill_tile_text">Text size</string> + <string name="browser_pill_tile_desktop">Desktop</string> + <string name="browser_pill_tile_home">Add to Home</string> + <string name="browser_pill_tile_other">Other browser</string> + <string name="browser_pill_tile_full">Full screen</string> + <string name="browser_pill_edit_address">Edit address</string> + <string name="browser_pill_back">Back</string> + <string name="browser_pill_forward">Forward</string> + <string name="browser_pill_reload">Reload</string> + <string name="browser_pill_stop">Stop loading</string> + <string name="browser_pill_favorite_add">Add to favorites</string> + <string name="browser_pill_favorite_remove">Remove from favorites</string> + <string name="browser_pill_share">Share</string> + <string name="browser_pill_copy">Copy link</string> + <string name="browser_pill_find">Find in page</string> + <string name="browser_pill_text_size">Text size</string> + <string name="browser_pill_add_home">Add to Home</string> + <string name="browser_pill_desktop">Desktop site</string> + <string name="browser_pill_other_browser">Open in browser</string> + <string name="browser_pill_full_screen">Full screen</string> + <string name="browser_pill_left_site">You left %1$s</string> + <string name="browser_pill_back_to_app">Back to app</string> + <string name="browser_pill_text_smaller">Smaller text</string> + <string name="browser_pill_text_larger">Larger text</string> + <string name="browser_pill_text_reset">Reset</string> + <string name="browser_pill_text_value">%1$d%</string> + <string name="browser_pill_privacy">Privacy</string> + <string name="browser_pill_tor_title">Onion routing</string> + <string name="browser_pill_tor_on">The site can't see your IP address</string> + <string name="browser_pill_tor_off">The site can see your IP address</string> + <string name="browser_pill_site_settings">Site settings</string> + <string name="browser_pill_site_settings_none">Nothing allowed yet</string> + <string name="browser_pill_access">What it can access</string> + <string name="browser_pill_access_desc">Your keys never leave Amethyst</string> + <string name="browser_pill_console">Console</string> + <string name="browser_pill_security_https">Secure connection</string> + <string name="browser_pill_security_http">Not secure</string> + <string name="browser_pill_security_tor">Onion-routed</string> + <string name="browser_pill_security_sandbox">Sandboxed app</string> + <string name="browser_pill_permission_camera">Camera</string> + <string name="browser_pill_permission_microphone">Microphone</string> + <string name="browser_pill_permission_location">Location</string> + <string name="browser_pill_decision_ask">Ask</string> + <string name="browser_pill_decision_allow">Allow</string> + <string name="browser_pill_decision_block">Block</string> + <string name="browser_pill_permission_state">%1$s %2$s</string> + <string name="browser_pill_decision_allowed">allowed</string> + <string name="browser_pill_decision_blocked">blocked</string> + <string name="browser_pill_address_hint">Search or enter address</string> + <string name="browser_pill_go">Go</string> + <string name="browser_pill_clear">Clear</string> + <string name="browser_pill_paste_go">Paste and go</string> + <string name="browser_pill_fill_in">Use this address</string> + <string name="browser_pill_find_hint">Find in page</string> + <string name="browser_pill_find_count">%1$d / %2$d</string> + <string name="browser_pill_find_none">No matches</string> + <string name="browser_pill_find_previous">Previous match</string> + <string name="browser_pill_find_next">Next match</string> + <string name="browser_pill_find_close">Close find in page</string> + <string name="browser_pill_console_all">All</string> + <string name="browser_pill_console_errors">Errors</string> + <string name="browser_pill_console_warnings">Warnings</string> + <string name="browser_pill_console_copy">Copy</string> + <string name="browser_pill_console_clear">Clear</string> + <string name="browser_pill_console_empty">Nothing logged yet</string> + <string name="browser_pill_perm_title">This site wants to use</string> + <string name="browser_pill_perm_camera_desc">See what your camera sees</string> + <string name="browser_pill_perm_microphone_desc">Hear what your microphone hears</string> + <string name="browser_pill_perm_location_desc">Know roughly where you are</string> + <string name="browser_pill_perm_tor_note">Calls can reveal your IP address even over Tor.</string> + <string name="browser_pill_perm_allow">Allow while visiting</string> + <string name="browser_pill_perm_once">Only this time</string> + <string name="browser_pill_perm_deny">Don't allow</string> + <string name="browser_pill_dialog_says">%1$s says</string> + <string name="browser_pill_dialog_generic">This page says</string> + <string name="browser_pill_dialog_block">Don't let this page show more dialogs</string> + <string name="browser_pill_dialog_answer">Your answer</string> + <string name="browser_pill_dialog_leave_title">Leave site?</string> + <string name="browser_pill_dialog_leave_message">Changes you made may not be saved.</string> + <string name="browser_pill_dialog_leave">Leave</string> + <string name="browser_pill_ok">OK</string> + <string name="browser_pill_cancel">Cancel</string> + <string name="browser_pill_info_connection">Connection</string> + <string name="browser_pill_info_https">Connection is encrypted</string> + <string name="browser_pill_info_https_desc">Information you send can't be read on the way.</string> + <string name="browser_pill_info_http">Connection is not encrypted</string> + <string name="browser_pill_info_http_desc">Don't enter passwords or payment details.</string> + <string name="browser_pill_info_tor">Onion-routed over Tor</string> + <string name="browser_pill_info_open">Open web</string> + <string name="browser_pill_info_certificate">Certificate</string> + <string name="browser_pill_info_certificate_desc">Issued to %1$s by %2$s · valid until %3$s</string> + <string name="browser_pill_info_permissions">Permissions</string> + <string name="browser_pill_info_site_data">Cookies and site data</string> + <string name="browser_pill_info_site_data_desc">Logins and preferences this site saved for this account</string> + <string name="browser_pill_info_clear">Clear site data</string> + <string name="browser_pill_info_clear_confirm">Sign out of this site and delete its data?</string> </resources> 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 new file mode 100644 index 0000000000..f46a97d35e --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt @@ -0,0 +1,219 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AssistChip +import androidx.compose.material3.AssistChipDefaults +import androidx.compose.material3.FilledIconButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.SolidColor +import androidx.compose.ui.text.TextRange +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.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_address_hint +import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_fill_in +import com.vitorpamplona.amethyst.commons.resources.browser_pill_go +import com.vitorpamplona.amethyst.commons.resources.browser_pill_paste_go +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. + */ +@Composable +fun AddressEditor( + initialUrl: String, + security: BrowserChrome.Security, + suggestions: List<AddressSuggestion>, + clipboardUrl: String?, + onGo: (String) -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + var field by remember { mutableStateOf(TextFieldValue(initialUrl, TextRange(0, initialUrl.length))) } + val focus = remember { FocusRequester() } + if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + + fun go(text: String = field.text) { + text.trim().takeIf { it.isNotEmpty() }?.let(onGo) + } + + Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = onCancel) { + Icon(MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = null) + } + Row( + Modifier + .weight(1f) + .height(56.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .border(2.dp, MaterialTheme.colorScheme.primary, CircleShape) + .padding(start = 16.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // The field shows what it will do: the page's badge while it still holds the page's URL, + // a search glyph once the user types something else. + if (field.text == initialUrl) { + SecurityIcon(security, size = 20.dp) + } else { + Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(10.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (field.text.isEmpty()) { + Text(stringRes(Res.string.browser_pill_address_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + BasicTextField( + value = field, + onValueChange = { field = it }, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = + KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Uri, + imeAction = ImeAction.Go, + ), + keyboardActions = KeyboardActions(onGo = { go() }), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (field.text.isNotEmpty()) { + IconButton(onClick = { field = TextFieldValue("") }) { + Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + FilledIconButton(onClick = { go() }, enabled = field.text.isNotBlank(), modifier = Modifier.size(44.dp)) { + Icon(MaterialSymbols.AutoMirrored.ArrowForward, contentDescription = stringRes(Res.string.browser_pill_go), modifier = Modifier.size(22.dp)) + } + } + } + + if (clipboardUrl != null && clipboardUrl != field.text) { + AssistChip( + onClick = { go(clipboardUrl) }, + label = { Text(stringRes(Res.string.browser_pill_paste_go) + " · " + BrowserChrome.displayHost(clipboardUrl), maxLines = 1, overflow = TextOverflow.Ellipsis) }, + leadingIcon = { Icon(MaterialSymbols.ContentPasteGo, contentDescription = null, modifier = Modifier.size(AssistChipDefaults.IconSize)) }, + modifier = Modifier.padding(start = 48.dp), + ) + } + + if (suggestions.isNotEmpty()) { + Column(Modifier.padding(start = 4.dp)) { + suggestions.take(MAX_SUGGESTIONS).forEach { suggestion -> + SuggestionRow( + suggestion = suggestion, + onOpen = { go(suggestion.url) }, + onFill = { field = TextFieldValue(suggestion.url, TextRange(suggestion.url.length)) }, + ) + } + } + } + } +} + +private const val MAX_SUGGESTIONS = 5 + +@Composable +private fun SuggestionRow( + suggestion: AddressSuggestion, + onOpen: () -> Unit, + onFill: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.medium) + .clickable(onClick = onOpen) + .padding(start = 8.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box { + SiteMonogram(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, size = 32.dp) + if (suggestion.isFavorite) { + Box( + Modifier + .align(Alignment.BottomEnd) + .size(14.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface), + contentAlignment = Alignment.Center, + ) { + Icon(MaterialSymbols.Star, contentDescription = null, modifier = Modifier.size(11.dp), tint = MaterialTheme.colorScheme.primary, filled = true) + } + } + } + Spacer(Modifier.width(14.dp)) + Column(Modifier.weight(1f)) { + Text(suggestion.title.ifBlank { BrowserChrome.displayHost(suggestion.url) }, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(suggestion.url.removePrefix("https://"), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + IconButton(onClick = onFill) { + Icon(MaterialSymbols.NorthWest, contentDescription = stringRes(Res.string.browser_pill_fill_in), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } +} 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 new file mode 100644 index 0000000000..e7bd6ca2a3 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -0,0 +1,503 @@ +/* + * 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.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.layout.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +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.saveable.rememberSaveable +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.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +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.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app +import com.vitorpamplona.amethyst.commons.resources.browser_pill_close +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allowed +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_blocked +import com.vitorpamplona.amethyst.commons.resources.browser_pill_left_site +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_state +import com.vitorpamplona.amethyst.commons.resources.browser_pill_privacy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_larger +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_reset +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_smaller +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_value +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * The redesigned browser pill (see `amethyst/plans/2026-09-26-browser-ui-review.md`): a grabber at the + * top centre that shows the page's state even collapsed, and, pulled down, a Chrome-PWA-style menu — + * header, navigation capsule, page-action tiles, a privacy card and the console row. + * + * Stateless apart from which inline panel (address editor, text size) is open. Which controls appear is + * [BrowserChrome]'s decision, so this renders web, nsite and napplet surfaces alike. + */ +@Composable +fun BrowserPill( + ui: BrowserPillUi, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + onEvent: (BrowserPillEvent) -> Unit, + modifier: Modifier = Modifier, + showClose: Boolean = false, + suggestions: List<AddressSuggestion> = emptyList(), + clipboardUrl: String? = null, + initiallyEditing: Boolean = false, + initiallyTextSizeOpen: Boolean = false, +) { + Column(modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + AnimatedVisibility( + visible = expanded, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + BrowserPillSheet( + ui = ui, + onEvent = { event -> + // Everything but in-sheet adjustments (text size) finishes the interaction. + if (event !is BrowserPillEvent.TextZoom) onExpandedChange(false) + onEvent(event) + }, + showClose = showClose, + suggestions = suggestions, + clipboardUrl = clipboardUrl, + initiallyEditing = initiallyEditing, + initiallyTextSizeOpen = initiallyTextSizeOpen, + ) + } + PillHandle(ui, expanded, onExpandedChange) + } +} + +/** + * The collapsed grabber. It tells the page's story before it's opened: the bar takes the error colour on + * plain HTTP and the Tor accent when onion-routed, a hairline fills while the page loads, and a dot + * appears when the console has errors. + */ +@Composable +fun PillHandle( + ui: BrowserPillUi, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val barColor = + when (ui.security) { + BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error + BrowserChrome.Security.TOR -> MaterialTheme.colorScheme.tertiary + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + } + Box( + modifier + .clip(RoundedCornerShape(bottomStart = 14.dp, bottomEnd = 14.dp)) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.72f)) + .clickable { onExpandedChange(!expanded) } + .draggable( + orientation = Orientation.Vertical, + state = + rememberDraggableState { delta -> + if (delta > 1f) { + onExpandedChange(true) + } else if (delta < -1f) { + onExpandedChange(false) + } + }, + ).padding(horizontal = 18.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + Modifier + .width(36.dp) + .height(5.dp) + .clip(CircleShape) + .background(barColor), + ) + val progress = ui.loadProgress + if (progress != null) { + Spacer(Modifier.height(3.dp)) + LinearProgressIndicator( + progress = { progress }, + modifier = Modifier.width(36.dp).height(2.dp).clip(CircleShape), + trackColor = Color.Transparent, + drawStopIndicator = {}, + ) + } + } + if (ui.consoleErrors > 0) { + Box( + Modifier + .align(Alignment.CenterEnd) + .padding(start = 44.dp) + .size(7.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error), + ) + } + } +} + +/** The expanded menu (without the grabber), for hosts that place it themselves. */ +@Composable +fun BrowserPillSheet( + ui: BrowserPillUi, + onEvent: (BrowserPillEvent) -> Unit, + modifier: Modifier = Modifier, + showClose: Boolean = false, + suggestions: List<AddressSuggestion> = emptyList(), + clipboardUrl: String? = null, + initiallyEditing: Boolean = false, + initiallyTextSizeOpen: Boolean = false, +) { + var editing by rememberSaveable { mutableStateOf(initiallyEditing) } + var textSizeOpen by rememberSaveable { mutableStateOf(initiallyTextSizeOpen) } + val sections = remember(ui.chrome) { BrowserChrome.sections(ui.chrome) } + val page = sections.firstOrNull { it.kind == SectionKind.PAGE }?.actions.orEmpty() + val privacy = sections.firstOrNull { it.kind == SectionKind.PRIVACY }?.actions.orEmpty() + val developer = sections.firstOrNull { it.kind == SectionKind.DEVELOPER }?.actions.orEmpty() + + Surface( + modifier = modifier.fillMaxWidth(), + shape = PillDefaults.SheetShape, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp, + shadowElevation = 8.dp, + // On the black dark theme a shadow doesn't show; a hairline keeps the sheet's edge off the page. + border = PillDefaults.hairline(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(start = PillDefaults.SheetPadding, end = PillDefaults.SheetPadding, top = 12.dp, bottom = 12.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (editing) { + AddressEditor( + initialUrl = ui.chrome.url, + security = ui.security, + suggestions = suggestions, + clipboardUrl = clipboardUrl, + onGo = { onEvent(BrowserPillEvent.Navigate(it)) }, + onCancel = { editing = false }, + ) + } else { + PillHeader(ui, showClose, onClose = { onEvent(BrowserPillEvent.Close) }) + OriginField( + ui = ui, + onEdit = if (Action.EDIT_ADDRESS in page) ({ editing = true }) else null, + onLongPress = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.CopyOrigin) }) else null, + ) + if (Action.BACK_TO_APP in page) { + OutOfScopeBanner(homeHost = BrowserChrome.displayHost(ui.chrome.startUrl)) { + onEvent(BrowserPillEvent.Action(Action.BACK_TO_APP)) + } + } + NavigationCapsule(ui, onAction = { onEvent(BrowserPillEvent.Action(it)) }) + + val tiles = page.filter { it != Action.BACK_TO_APP && it != Action.EDIT_ADDRESS } + if (tiles.isNotEmpty()) { + TileGrid( + actions = tiles, + ui = ui, + textSizeOpen = textSizeOpen, + onAction = { action -> + if (action == Action.TEXT_SIZE) textSizeOpen = !textSizeOpen else onEvent(BrowserPillEvent.Action(action)) + }, + ) + } + AnimatedVisibility(visible = textSizeOpen) { + TextSizeControl(ui.textZoom) { onEvent(BrowserPillEvent.TextZoom(it)) } + } + if (privacy.isNotEmpty()) { + PrivacyCard(ui, privacy) { onEvent(BrowserPillEvent.Action(it)) } + } + if (Action.CONSOLE in developer) { + ConsoleRow(ui) { onEvent(BrowserPillEvent.Action(Action.CONSOLE)) } + } + } + } + } +} + +@Composable +private fun PillHeader( + ui: BrowserPillUi, + showClose: Boolean, + onClose: () -> Unit, +) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(ui.title.ifBlank { ui.host }) + Spacer(Modifier.width(12.dp)) + Text( + ui.title.ifBlank { ui.host }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + if (showClose) { + IconButton(onClick = onClose) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_close), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } +} + +/** Chrome's out-of-scope bar, inside the menu: you're on another site now; one tap goes home. */ +@Composable +private fun OutOfScopeBanner( + homeHost: String, + onBack: () -> Unit, +) { + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(start = 14.dp, end = 4.dp, top = 2.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Info, contentDescription = null, modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSecondaryContainer) + Spacer(Modifier.width(10.dp)) + Text( + stringRes(Res.string.browser_pill_left_site, homeHost), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + TextButton(onClick = onBack) { Text(stringRes(Res.string.browser_pill_back_to_app)) } + } +} + +/** Page actions as tiles, four per row; toggles (desktop site, text size) fill while on. */ +@Composable +private fun TileGrid( + actions: List<Action>, + ui: BrowserPillUi, + textSizeOpen: Boolean, + onAction: (Action) -> Unit, +) { + val columns = 4 + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + actions.chunked(columns).forEach { rowActions -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + rowActions.forEach { action -> + ActionTile( + symbol = pillSymbolFor(action) ?: MaterialSymbols.Info, + label = stringRes(pillTileLabelFor(action)), + description = stringRes(pillLabelFor(action)), + onClick = { onAction(action) }, + selected = + when (action) { + Action.DESKTOP_SITE -> ui.desktopSite + Action.TEXT_SIZE -> textSizeOpen || ui.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM + else -> null + }, + modifier = Modifier.weight(1f), + ) + } + // Keep the last row's tiles the same width as the others. + repeat(columns - rowActions.size) { Spacer(Modifier.weight(1f)) } + } + } + } +} + +/** Text size: a stepped slider between a small and a large "A", the value, and a way back to 100%. */ +@Composable +fun TextSizeControl( + percent: Int, + onChange: (Int) -> Unit, +) { + val steps = BrowserChrome.TEXT_ZOOM_STEPS + val index = steps.indexOfFirst { it >= percent }.let { if (it < 0) steps.lastIndex else it } + Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + val smaller = stringRes(Res.string.browser_pill_text_smaller) + IconButton( + onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = false)) }, + modifier = Modifier.semantics { contentDescription = smaller }, + ) { + Text("A", fontSize = 14.sp, fontWeight = FontWeight.Medium, modifier = Modifier.padding(top = 4.dp)) + } + Slider( + value = index.toFloat(), + onValueChange = { onChange(steps[it.toInt().coerceIn(0, steps.lastIndex)]) }, + valueRange = 0f..steps.lastIndex.toFloat(), + steps = steps.size - 2, + modifier = Modifier.weight(1f), + ) + val larger = stringRes(Res.string.browser_pill_text_larger) + IconButton( + onClick = { onChange(BrowserChrome.stepTextZoom(percent, larger = true)) }, + modifier = Modifier.semantics { contentDescription = larger }, + ) { + Text("A", fontSize = 22.sp, fontWeight = FontWeight.Medium) + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + stringRes(Res.string.browser_pill_text_value, percent), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.weight(1f).padding(start = 12.dp), + ) + TextButton(onClick = { onChange(BrowserChrome.DEFAULT_TEXT_ZOOM) }, enabled = percent != BrowserChrome.DEFAULT_TEXT_ZOOM) { + Text(stringRes(Res.string.browser_pill_text_reset)) + } + } + } + } +} + +/** + * Privacy: Tor with what it means for the site, site settings with a summary of what the site may use, + * and, for sandboxed apps, what they can access. + */ +@Composable +private fun PrivacyCard( + ui: BrowserPillUi, + actions: List<Action>, + onAction: (Action) -> Unit, +) { + GroupCard(heading = stringRes(Res.string.browser_pill_privacy)) { + actions.forEachIndexed { index, action -> + if (index > 0) GroupDivider() + when (action) { + Action.TOR -> { + val on = ui.chrome.torOn == true + GroupRow( + icon = { PillActionIcon(Action.TOR, tint = if (on) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (on) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(pillLabelFor(Action.TOR)), + supporting = stringRes(if (on) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = { onAction(Action.TOR) }, + ) { Switch(checked = on, onCheckedChange = { onAction(Action.TOR) }) } + } + Action.SITE_SETTINGS -> + GroupRow( + icon = { PillActionIcon(Action.SITE_SETTINGS, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(pillLabelFor(Action.SITE_SETTINGS)), + supporting = permissionSummary(ui.sitePermissions), + onClick = { onAction(Action.SITE_SETTINGS) }, + ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + Action.ACCESS_INFO -> + GroupRow( + icon = { PillActionIcon(Action.ACCESS_INFO, tint = MaterialTheme.colorScheme.onPrimaryContainer, size = 22.dp, filled = true) }, + iconContainer = MaterialTheme.colorScheme.primaryContainer, + title = stringRes(pillLabelFor(Action.ACCESS_INFO)), + supporting = stringRes(Res.string.browser_pill_access_desc), + onClick = { onAction(Action.ACCESS_INFO) }, + ) { Icon(MaterialSymbols.ChevronRight, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) } + else -> Unit + } + } + } +} + +@Composable +private fun permissionSummary(decisions: Map<BrowserSitePermission, BrowserSitePermission.Decision>): String { + val answered = BrowserSitePermission.entries.mapNotNull { permission -> decisions[permission]?.takeIf { it != BrowserSitePermission.Decision.ASK }?.let { permission to it } } + if (answered.isEmpty()) return stringRes(Res.string.browser_pill_site_settings_none) + return answered + .map { (permission, decision) -> + stringRes( + Res.string.browser_pill_permission_state, + stringRes(permissionLabel(permission)), + stringRes(if (decision == BrowserSitePermission.Decision.ALLOW) Res.string.browser_pill_decision_allowed else Res.string.browser_pill_decision_blocked), + ) + }.joinToString(" · ") +} + +/** The developer console: one row, with an error count badge and its on/off switch. */ +@Composable +private fun ConsoleRow( + ui: BrowserPillUi, + onToggle: () -> Unit, +) { + Surface(onClick = onToggle, shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Row(Modifier.padding(start = 16.dp, end = 12.dp).heightIn(min = 56.dp), verticalAlignment = Alignment.CenterVertically) { + PillActionIcon(Action.CONSOLE, tint = MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) + Spacer(Modifier.width(16.dp)) + Text(stringRes(pillLabelFor(Action.CONSOLE)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + if (ui.consoleErrors > 0) { + CountBadge(ui.consoleErrors) + Spacer(Modifier.width(12.dp)) + } + Switch(checked = ui.consoleShowing, onCheckedChange = { onToggle() }) + } + } +} 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 new file mode 100644 index 0000000000..b9c78e7e1f --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt @@ -0,0 +1,90 @@ +/* + * 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.Immutable +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission + +/** + * Everything the redesigned browser pill draws, as plain immutable data. [chrome] decides *which* controls + * exist (the same [BrowserChrome] layout both browsers already share); the rest is what they show. + * + * The pill composables are stateless: a host (embedded tab, full-screen window, desktop) builds this from + * its live page and handles [BrowserPillEvent]s. + */ +@Immutable +data class BrowserPillUi( + val title: String, + val chrome: BrowserChrome.State, + val isFavorite: Boolean = false, + val desktopSite: Boolean = false, + val textZoom: Int = BrowserChrome.DEFAULT_TEXT_ZOOM, + /** 0..1 while the main frame loads; null when idle. */ + val loadProgress: Float? = null, + val consoleShowing: Boolean = false, + val consoleErrors: Int = 0, + /** Answers this site already has (camera / mic / location), for the site-settings summary. */ + val sitePermissions: Map<BrowserSitePermission, BrowserSitePermission.Decision> = emptyMap(), +) { + val security: BrowserChrome.Security get() = BrowserChrome.security(chrome) + val host: String get() = BrowserChrome.displayHost(chrome.url) +} + +/** What the user did in the pill. Hosts map these onto the WebView / broker. */ +sealed interface BrowserPillEvent { + /** A navigation-capsule button, a tile, or a privacy/developer row. */ + data class Action( + val action: BrowserChrome.Action, + ) : BrowserPillEvent + + data class TextZoom( + val percent: Int, + ) : BrowserPillEvent + + data class Navigate( + val input: String, + ) : BrowserPillEvent + + /** The origin field was long-pressed (copy link). */ + data object CopyOrigin : BrowserPillEvent + + data object Close : BrowserPillEvent +} + +/** One omnibox suggestion for the address editor. */ +@Immutable +data class AddressSuggestion( + val title: String, + val url: String, + val isFavorite: Boolean = false, +) + +/** One console line. */ +@Immutable +data class ConsoleLine( + val level: Level, + val message: String, + val source: String = "", + val line: Int = 0, +) { + enum class Level { LOG, INFO, WARNING, ERROR, DEBUG } +} 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 new file mode 100644 index 0000000000..fce006a7e9 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -0,0 +1,240 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow + +/** Sample states for the pill prototypes, shared by the previews and the offscreen render test. */ +object BrowserPillSamples { + val primal = + BrowserPillUi( + title = "Primal", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = "https://primal.net/home", + startUrl = "https://primal.net/", + canGoBack = true, + torOn = false, + ), + isFavorite = true, + consoleErrors = 3, + sitePermissions = mapOf(BrowserSitePermission.CAMERA to Decision.ALLOW, BrowserSitePermission.LOCATION to Decision.BLOCK), + ) + + val overTorOutOfScope = + BrowserPillUi( + title = "Sign in – Accounts", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.WEB, + presentation = BrowserChrome.Presentation.EMBEDDED, + url = "https://accounts.example.com/login", + startUrl = "https://snort.social/", + canGoBack = true, + canGoForward = true, + isLoading = true, + torOn = true, + ), + loadProgress = 0.62f, + textZoom = 115, + desktopSite = true, + ) + + val insecure = + BrowserPillUi( + title = "Old Forum", + chrome = BrowserChrome.State(BrowserChrome.Surface.WEB, BrowserChrome.Presentation.EMBEDDED, "http://oldforum.example.org/", "http://oldforum.example.org/"), + loadProgress = 0.3f, + ) + + val napplet = + BrowserPillUi( + title = "Zap Poll", + chrome = + BrowserChrome.State( + surface = BrowserChrome.Surface.NAPPLET, + presentation = BrowserChrome.Presentation.FULL_SCREEN, + url = "", + startUrl = "", + canFavorite = true, + hasAccessInfo = true, + torOn = true, + ), + ) + + val suggestions = + listOf( + AddressSuggestion("Primal", "https://primal.net/", isFavorite = true), + AddressSuggestion("Snort", "https://snort.social/"), + AddressSuggestion("Habla — long-form Nostr", "https://habla.news/"), + AddressSuggestion("", "https://nostr.band/search?q=amethyst"), + ) + + val console = + listOf( + ConsoleLine(ConsoleLine.Level.INFO, "Connected to wss://relay.damus.io", "app.js", 112), + ConsoleLine(ConsoleLine.Level.WARNING, "window.nostr.getRelays is deprecated", "nostr.js", 40), + ConsoleLine(ConsoleLine.Level.LOG, "feed: 42 events in 180ms", "feed.js", 9), + ConsoleLine(ConsoleLine.Level.ERROR, "Uncaught TypeError: Cannot read properties of undefined (reading 'pubkey')", "https://primal.net/assets/index-4f2a.js", 2211), + ConsoleLine(ConsoleLine.Level.ERROR, "Failed to load (-2): net::ERR_NAME_NOT_RESOLVED", "https://cdn.example.com/x.png", 0), + ConsoleLine(ConsoleLine.Level.DEBUG, "cache hit: profile 7a1c…", "cache.js", 77), + ) + + val certificate = CertificateInfo(issuedTo = "primal.net", issuedBy = "Let's Encrypt", validUntil = "Dec 14, 2026") +} + +@Composable +private fun PreviewFrame(content: @Composable () -> Unit) { + ThemeComparisonRow { + Box(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(bottom = 12.dp)) { content() } + } +} + +@Preview(widthDp = 820, heightDp = 1000) +@Composable +fun BrowserPillExpandedPreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.primal, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) } +} + +@Preview(widthDp = 820, heightDp = 1100) +@Composable +fun BrowserPillTorOutOfScopePreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.overTorOutOfScope, expanded = true, onExpandedChange = {}, onEvent = {}, initiallyTextSizeOpen = true) } +} + +@Preview(widthDp = 820, heightDp = 700) +@Composable +fun BrowserPillAddressEditorPreview() { + PreviewFrame { + BrowserPill( + BrowserPillSamples.primal, + expanded = true, + onExpandedChange = {}, + onEvent = {}, + suggestions = BrowserPillSamples.suggestions, + clipboardUrl = "https://njump.me/npub1gcxzte5zlkncx26j68ez60fzkvtkm9e0vrwdcvsjakxf9mu9qewqlfnj5z", + initiallyEditing = true, + ) + } +} + +@Preview(widthDp = 820, heightDp = 800) +@Composable +fun BrowserPillNappletPreview() { + PreviewFrame { BrowserPill(BrowserPillSamples.napplet, expanded = true, onExpandedChange = {}, onEvent = {}, showClose = true) } +} + +@Preview(widthDp = 820, heightDp = 160) +@Composable +fun PillHandlesPreview() { + ThemeComparisonRow { + Row(Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceDim).padding(vertical = 16.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.Top) { + PillHandle(BrowserPillSamples.primal.copy(consoleErrors = 0), expanded = false, onExpandedChange = {}) + PillHandle(BrowserPillSamples.insecure, expanded = false, onExpandedChange = {}) + PillHandle(BrowserPillSamples.overTorOutOfScope.copy(consoleErrors = 2), expanded = false, onExpandedChange = {}) + } + } +} + +@Preview(widthDp = 820, heightDp = 220) +@Composable +fun FindInPagePreview() { + ThemeComparisonRow { + Column(Modifier.background(MaterialTheme.colorScheme.surfaceDim)) { + FindInPagePill(query = "zap", onQueryChange = {}, active = 2, total = 12, onNext = {}, onClose = {}, autoFocus = false) + FindInPagePill(query = "lightning address", onQueryChange = {}, active = 0, total = 0, onNext = {}, onClose = {}, autoFocus = false) + } + } +} + +@Preview(widthDp = 820, heightDp = 380) +@Composable +fun ConsoleSheetPreview() { + ThemeComparisonRow { ConsoleSheet(BrowserPillSamples.console, onCopy = {}, onClear = {}, onCopyLine = {}) } +} + +@Preview(widthDp = 820, heightDp = 700) +@Composable +fun PermissionPromptPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PermissionPromptCard("meet.example.com", BrowserChrome.Security.TOR, setOf(BrowserSitePermission.CAMERA, BrowserSitePermission.MICROPHONE), onAllow = {}, onAllowOnce = {}, onDeny = {}) + } + } +} + +@Preview(widthDp = 820, heightDp = 560) +@Composable +fun PageDialogPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PageDialogCard( + type = PageDialogType.PROMPT, + host = "snort.social", + security = BrowserChrome.Security.HTTPS, + message = "Name this relay set", + defaultValue = "Friends", + offerBlock = true, + onResult = { _, _, _ -> }, + autoFocus = false, + ) + } + } +} + +@Preview(widthDp = 820, heightDp = 420) +@Composable +fun LeaveSiteDialogPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp)) { + PageDialogCard(PageDialogType.BEFORE_UNLOAD, "habla.news", BrowserChrome.Security.HTTPS, message = "", onResult = { _, _, _ -> }) + } + } +} + +@Preview(widthDp = 820, heightDp = 1250) +@Composable +fun PageInfoPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp).width(380.dp)) { + PageInfoSheet(BrowserPillSamples.primal, BrowserPillSamples.certificate, onPermissionChange = { _, _ -> }, onClearSiteData = {}, initiallyConfirmingClear = 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 new file mode 100644 index 0000000000..28de782946 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +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_console +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_all +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_empty +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_errors +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console_warnings +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** Which console lines are shown. */ +enum class ConsoleFilter { ALL, ERRORS, WARNINGS } + +/** + * The developer console as a bottom sheet: a grab handle, the title with All / Errors / Warnings filter + * chips (with counts), Copy and Clear, then the log — one row per line with a level-coloured stripe, the + * message in monospace and `file:line` muted. Long-press a row to copy it. + */ +@Composable +fun ConsoleSheet( + lines: List<ConsoleLine>, + onCopy: (List<ConsoleLine>) -> Unit, + onClear: () -> Unit, + onCopyLine: (ConsoleLine) -> Unit, + modifier: Modifier = Modifier, + maxHeight: Dp = 320.dp, + initialFilter: ConsoleFilter = ConsoleFilter.ALL, +) { + var filter by remember { mutableStateOf(initialFilter) } + val errors = lines.count { it.level == ConsoleLine.Level.ERROR } + val warnings = lines.count { it.level == ConsoleLine.Level.WARNING } + val shown = + when (filter) { + ConsoleFilter.ALL -> lines + ConsoleFilter.ERRORS -> lines.filter { it.level == ConsoleLine.Level.ERROR } + ConsoleFilter.WARNINGS -> lines.filter { it.level == ConsoleLine.Level.WARNING } + } + + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + color = MaterialTheme.colorScheme.surfaceContainerLow, + shadowElevation = 8.dp, + border = PillDefaults.hairline(), + ) { + Column(Modifier.heightIn(max = maxHeight)) { + Box(Modifier.fillMaxWidth().padding(top = 10.dp), contentAlignment = Alignment.Center) { + Box( + Modifier + .width(32.dp) + .height(4.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), + ) + } + Row(Modifier.padding(start = 16.dp, end = 4.dp, top = 6.dp), verticalAlignment = Alignment.CenterVertically) { + Text(stringRes(Res.string.browser_pill_console), style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + IconButton(onClick = { onCopy(shown) }, enabled = shown.isNotEmpty()) { + Icon(MaterialSymbols.ContentCopy, contentDescription = stringRes(Res.string.browser_pill_console_copy), modifier = Modifier.size(20.dp)) + } + IconButton(onClick = onClear, enabled = lines.isNotEmpty()) { + Icon(MaterialSymbols.Delete, contentDescription = stringRes(Res.string.browser_pill_console_clear), modifier = Modifier.size(20.dp)) + } + } + Row( + Modifier.horizontalScroll(rememberScrollState()).padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ConsoleChip(stringRes(Res.string.browser_pill_console_all), lines.size, filter == ConsoleFilter.ALL) { filter = ConsoleFilter.ALL } + ConsoleChip(stringRes(Res.string.browser_pill_console_errors), errors, filter == ConsoleFilter.ERRORS, MaterialTheme.colorScheme.error) { filter = ConsoleFilter.ERRORS } + ConsoleChip(stringRes(Res.string.browser_pill_console_warnings), warnings, filter == ConsoleFilter.WARNINGS, warningColor()) { filter = ConsoleFilter.WARNINGS } + } + if (shown.isEmpty()) { + Text( + stringRes(Res.string.browser_pill_console_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + ) + } else { + LazyColumn(Modifier.padding(top = 6.dp, bottom = 8.dp)) { + items(shown) { line -> ConsoleRowItem(line) { onCopyLine(line) } } + } + } + } + } +} + +@Composable +private fun ConsoleChip( + label: String, + count: Int, + selected: Boolean, + dot: Color? = null, + onClick: () -> Unit, +) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text("$label $count") }, + leadingIcon = + dot?.let { color -> + { Box(Modifier.size(8.dp).clip(CircleShape).background(color)) } + }, + colors = FilterChipDefaults.filterChipColors(selectedContainerColor = MaterialTheme.colorScheme.secondaryContainer), + ) +} + +@Composable +private fun warningColor(): Color = if (MaterialTheme.colorScheme.surface.luminanceBelowHalf()) Color(0xFFFFB74D) else Color(0xFFB45309) + +private fun Color.luminanceBelowHalf(): Boolean = (0.299f * red + 0.587f * green + 0.114f * blue) < 0.5f + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ConsoleRowItem( + line: ConsoleLine, + onLongPress: () -> Unit, +) { + val color = + when (line.level) { + ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.error + ConsoleLine.Level.WARNING -> warningColor() + ConsoleLine.Level.DEBUG -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + } + val background = + when (line.level) { + ConsoleLine.Level.ERROR -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.35f) + ConsoleLine.Level.WARNING -> warningColor().copy(alpha = 0.10f) + else -> Color.Transparent + } + Row( + Modifier + .fillMaxWidth() + .height(IntrinsicSize.Min) + .background(background) + .combinedClickable(onClick = {}, onLongClick = onLongPress) + .padding(end = 16.dp), + verticalAlignment = Alignment.Top, + ) { + Box(Modifier.width(3.dp).fillMaxHeight().background(if (line.level == ConsoleLine.Level.LOG) Color.Transparent else color)) + Spacer(Modifier.width(13.dp)) + Text( + line.message, + color = color, + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + lineHeight = 16.sp, + modifier = Modifier.weight(1f).padding(vertical = 5.dp), + ) + if (line.source.isNotBlank()) { + Spacer(Modifier.width(8.dp)) + Text( + line.source.substringAfterLast('/') + ":" + line.line, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(vertical = 5.dp).width(96.dp), + ) + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt new file mode 100644 index 0000000000..c38bdfbc2c --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/FindInPagePill.kt @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicTextField +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.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +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.SolidColor +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.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_close +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_count +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_hint +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_next +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find_previous +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * Find in page as a floating capsule above the bottom edge: search glyph, the query, a match chip + * ("3 / 12", or "No matches" in the error tone), previous / next, and close. IME Search jumps to the next + * match. [active] is 0-based; [total] null means no search has run yet. + */ +@Composable +fun FindInPagePill( + query: String, + onQueryChange: (String) -> Unit, + active: Int, + total: Int?, + onNext: (forward: Boolean) -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, + autoFocus: Boolean = true, +) { + val focus = remember { FocusRequester() } + if (autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + val noMatches = query.isNotEmpty() && total == 0 + + Surface( + modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 12.dp), + shape = CircleShape, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 8.dp, + border = PillDefaults.hairline(), + ) { + Row(Modifier.height(56.dp).padding(start = 18.dp, end = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.Search, contentDescription = null, modifier = Modifier.size(20.dp), tint = if (noMatches) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + if (query.isEmpty()) { + Text(stringRes(Res.string.browser_pill_find_hint), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + BasicTextField( + value = query, + onValueChange = onQueryChange, + singleLine = true, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onNext(true) }), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (query.isNotEmpty() && total != null) { + Spacer(Modifier.width(8.dp)) + Box( + Modifier + .clip(CircleShape) + .background(if (noMatches) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 10.dp, vertical = 4.dp), + ) { + Text( + if (noMatches) stringRes(Res.string.browser_pill_find_none) else stringRes(Res.string.browser_pill_find_count, active + 1, total), + style = MaterialTheme.typography.labelMedium, + color = if (noMatches) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + IconButton(onClick = { onNext(false) }, enabled = (total ?: 0) > 0) { + Icon(MaterialSymbols.KeyboardArrowUp, contentDescription = stringRes(Res.string.browser_pill_find_previous)) + } + IconButton(onClick = { onNext(true) }, enabled = (total ?: 0) > 0) { + Icon(MaterialSymbols.KeyboardArrowDown, contentDescription = stringRes(Res.string.browser_pill_find_next)) + } + VerticalDivider(Modifier.height(24.dp), color = MaterialTheme.colorScheme.outlineVariant) + IconButton(onClick = onClose) { + Icon(MaterialSymbols.Close, contentDescription = stringRes(Res.string.browser_pill_find_close)) + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt new file mode 100644 index 0000000000..8c28a74bc7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PageSheets.kt @@ -0,0 +1,476 @@ +/* + * 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.animation.AnimatedContent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +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.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +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.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow +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.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.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +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.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission.Decision +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_cancel +import com.vitorpamplona.amethyst.commons.resources.browser_pill_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allow +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_ask +import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_block +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_answer +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_block +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_generic +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_message +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_leave_title +import com.vitorpamplona.amethyst.commons.resources.browser_pill_dialog_says +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_certificate_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_clear_confirm +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_http_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_https_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_permissions +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_site_data_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_allow +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_camera_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_deny +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_location_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_microphone_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_once +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_title +import com.vitorpamplona.amethyst.commons.resources.browser_pill_perm_tor_note +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** A compact, read-only origin badge for card headers: the page can't fake it, so every card starts with it. */ +@Composable +fun OriginBadge( + host: String, + security: BrowserChrome.Security, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(start = 10.dp, end = 12.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SecurityIcon(security, size = 16.dp) + Spacer(Modifier.width(6.dp)) + Text(host, style = MaterialTheme.typography.labelLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + } +} + +/** The card frame every page-initiated prompt shares. */ +@Composable +private fun PageCard(content: @Composable () -> Unit) { + Surface( + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(24.dp)) { content() } + } +} + +/** + * A site asking for camera / microphone / location: the origin badge, a tinted circle per thing asked with + * what it means, a note when calls over Tor could still expose the IP, and three plain-worded answers — + * remembered allow, one-time allow, remembered refusal. + */ +@Composable +fun PermissionPromptCard( + host: String, + security: BrowserChrome.Security, + permissions: Set<BrowserSitePermission>, + onAllow: () -> Unit, + onAllowOnce: () -> Unit, + onDeny: () -> Unit, +) { + PageCard { + OriginBadge(host, security) + Spacer(Modifier.height(20.dp)) + Text( + stringRes(Res.string.browser_pill_perm_title), + style = MaterialTheme.typography.titleLarge, + ) + Spacer(Modifier.height(16.dp)) + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + permissions.sortedBy { it.ordinal }.forEach { permission -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier.size(44.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, filled = true) + } + Spacer(Modifier.width(14.dp)) + Column { + Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.titleSmall) + Text( + stringRes( + when (permission) { + BrowserSitePermission.CAMERA -> Res.string.browser_pill_perm_camera_desc + BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_perm_microphone_desc + BrowserSitePermission.LOCATION -> Res.string.browser_pill_perm_location_desc + }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + // WebRTC can reach out around the SOCKS proxy; say so where it matters, without blocking the call. + if (security == BrowserChrome.Security.TOR && (BrowserSitePermission.CAMERA in permissions || BrowserSitePermission.MICROPHONE in permissions)) { + Spacer(Modifier.height(16.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.6f)) + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + PillActionIcon(BrowserChrome.Action.TOR, tint = MaterialTheme.colorScheme.onTertiaryContainer, size = 18.dp) + Spacer(Modifier.width(10.dp)) + Text(stringRes(Res.string.browser_pill_perm_tor_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onTertiaryContainer) + } + } + Spacer(Modifier.height(24.dp)) + Button(onClick = onAllow, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_allow)) } + Spacer(Modifier.height(8.dp)) + FilledTonalButton(onClick = onAllowOnce, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_once)) } + Spacer(Modifier.height(4.dp)) + TextButton(onClick = onDeny, modifier = Modifier.fillMaxWidth().height(48.dp)) { Text(stringRes(Res.string.browser_pill_perm_deny)) } + } +} + +/** The kinds of page dialog. */ +enum class PageDialogType { ALERT, CONFIRM, PROMPT, BEFORE_UNLOAD } + +/** + * A page's alert / confirm / prompt / beforeunload. The origin badge heads it (so it can't pass for + * Amethyst UI); prompt gets a labelled field with clear and IME Done; repeat dialogs offer a checkbox to + * block the rest, as Chrome does. + */ +@Composable +fun PageDialogCard( + type: PageDialogType, + host: String?, + security: BrowserChrome.Security, + message: String, + defaultValue: String = "", + offerBlock: Boolean = false, + onResult: (confirmed: Boolean, text: String?, block: Boolean) -> Unit, + autoFocus: Boolean = true, +) { + var text by remember { mutableStateOf(defaultValue) } + var block by remember { mutableStateOf(false) } + val leave = type == PageDialogType.BEFORE_UNLOAD + val focus = remember { FocusRequester() } + if (type == PageDialogType.PROMPT && autoFocus) LaunchedEffect(Unit) { runCatching { focus.requestFocus() } } + + PageCard { + if (host != null) { + OriginBadge(host, security) + Spacer(Modifier.height(16.dp)) + } + Text( + when { + leave -> stringRes(Res.string.browser_pill_dialog_leave_title) + host != null -> stringRes(Res.string.browser_pill_dialog_says, host) + else -> stringRes(Res.string.browser_pill_dialog_generic) + }, + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(12.dp)) + Column(Modifier.heightIn(max = 240.dp).verticalScroll(rememberScrollState())) { + Text( + if (leave) stringRes(Res.string.browser_pill_dialog_leave_message) else message, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (type == PageDialogType.PROMPT) { + Spacer(Modifier.height(16.dp)) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + label = { Text(stringRes(Res.string.browser_pill_dialog_answer)) }, + singleLine = true, + trailingIcon = { + if (text.isNotEmpty()) { + IconButton(onClick = { text = "" }) { Icon(MaterialSymbols.Cancel, contentDescription = stringRes(Res.string.browser_pill_clear)) } + } + }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { onResult(true, text, block) }), + shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth().focusRequester(focus), + ) + } + if (offerBlock) { + Spacer(Modifier.height(8.dp)) + Row( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable { block = !block } + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox(checked = block, onCheckedChange = { block = it }) + Text(stringRes(Res.string.browser_pill_dialog_block), style = MaterialTheme.typography.bodyMedium) + } + } + Spacer(Modifier.height(20.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + if (type != PageDialogType.ALERT) { + TextButton(onClick = { onResult(false, null, block) }) { Text(stringRes(Res.string.browser_pill_cancel)) } + Spacer(Modifier.width(8.dp)) + } + Button( + onClick = { onResult(true, if (type == PageDialogType.PROMPT) text else null, block) }, + colors = if (leave) ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError) else ButtonDefaults.buttonColors(), + ) { + Text(stringRes(if (leave) Res.string.browser_pill_dialog_leave else Res.string.browser_pill_ok)) + } + } + } +} + +/** Certificate facts for [PageInfoSheet]. */ +data class CertificateInfo( + val issuedTo: String, + val issuedBy: String, + val validUntil: String, +) + +/** + * Page info as a sheet: who the site is, how you're connected (encryption, route, certificate), what it + * may use (camera / mic / location as Ask · Allow · Block, editable in place), and its stored data with a + * clear that asks first. + */ +@Composable +fun PageInfoSheet( + ui: BrowserPillUi, + certificate: CertificateInfo?, + onPermissionChange: (BrowserSitePermission, Decision) -> Unit, + onClearSiteData: () -> Unit, + modifier: Modifier = Modifier, + initiallyConfirmingClear: Boolean = false, +) { + var confirmingClear by remember { mutableStateOf(initiallyConfirmingClear) } + val https = ui.chrome.url.startsWith("https://", ignoreCase = true) + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + ) { + Column(Modifier.padding(PillDefaults.SheetPadding), verticalArrangement = Arrangement.spacedBy(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(ui.title.ifBlank { ui.host }, size = 48.dp) + Spacer(Modifier.width(14.dp)) + Column { + Text(ui.host, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(BrowserChrome.originOf(ui.chrome.url) ?: ui.chrome.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) { + GroupRow( + icon = { Icon(if (https) MaterialSymbols.Lock else MaterialSymbols.NoEncryption, contentDescription = null, tint = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onErrorContainer, filled = true) }, + iconContainer = if (https) MaterialTheme.colorScheme.surfaceContainerHighest else MaterialTheme.colorScheme.errorContainer, + title = stringRes(if (https) Res.string.browser_pill_info_https else Res.string.browser_pill_info_http), + supporting = stringRes(if (https) Res.string.browser_pill_info_https_desc else Res.string.browser_pill_info_http_desc), + supportingColor = if (https) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.error, + onClick = null, + ) + ui.chrome.torOn?.let { tor -> + GroupDivider() + GroupRow( + icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open), + supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = null, + ) + } + if (certificate != null) { + GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_info_certificate), + supporting = stringRes(Res.string.browser_pill_info_certificate_desc, certificate.issuedTo, certificate.issuedBy, certificate.validUntil), + onClick = null, + ) + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_permissions)) { + BrowserSitePermission.entries.forEachIndexed { index, permission -> + if (index > 0) GroupDivider() + PermissionDecisionRow(permission, ui.sitePermissions[permission] ?: Decision.ASK) { onPermissionChange(permission, it) } + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_info_site_data)) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(MaterialSymbols.Cookie, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + Spacer(Modifier.width(12.dp)) + Text(stringRes(Res.string.browser_pill_info_site_data_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.height(12.dp)) + AnimatedContent(targetState = confirmingClear, label = "clear-site-data") { confirming -> + if (!confirming) { + OutlinedButton( + onClick = { confirmingClear = true }, + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Icon(MaterialSymbols.Delete, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringRes(Res.string.browser_pill_info_clear)) + } + } else { + Column( + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(MaterialTheme.colorScheme.errorContainer) + .padding(12.dp), + ) { + Text(stringRes(Res.string.browser_pill_info_clear_confirm), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onErrorContainer) + Spacer(Modifier.height(8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + TextButton(onClick = { confirmingClear = false }) { Text(stringRes(Res.string.browser_pill_cancel), color = MaterialTheme.colorScheme.onErrorContainer) } + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + confirmingClear = false + onClearSiteData() + }, + colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error, contentColor = MaterialTheme.colorScheme.onError), + ) { Text(stringRes(Res.string.browser_pill_info_clear)) } + } + } + } + } + } + } + } + } +} + +/** One permission with a first-class Ask · Allow · Block choice. */ +@Composable +private fun PermissionDecisionRow( + permission: BrowserSitePermission, + decision: Decision, + onChange: (Decision) -> Unit, +) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.size(40.dp).clip(CircleShape).background(MaterialTheme.colorScheme.surfaceContainerHighest), contentAlignment = Alignment.Center) { + Icon(permissionSymbol(permission), contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(12.dp)) + Text(stringRes(permissionLabel(permission)), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(8.dp)) + val options = listOf(Decision.ASK to Res.string.browser_pill_decision_ask, Decision.ALLOW to Res.string.browser_pill_decision_allow, Decision.BLOCK to Res.string.browser_pill_decision_block) + SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth().padding(start = 52.dp)) { + options.forEachIndexed { index, (option, label) -> + SegmentedButton( + selected = decision == option, + onClick = { onChange(option) }, + shape = SegmentedButtonDefaults.itemShape(index, options.size), + icon = {}, + label = { Text(stringRes(label), textAlign = TextAlign.Center, maxLines = 1) }, + ) + } + } + } +} 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 new file mode 100644 index 0000000000..1dc373369b --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt @@ -0,0 +1,348 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +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.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** Shapes and sizes shared by every pill surface, so they read as one family. */ +object PillDefaults { + val SheetShape = RoundedCornerShape(bottomStart = 28.dp, bottomEnd = 28.dp) + val CardShape = RoundedCornerShape(20.dp) + val TileShape = RoundedCornerShape(18.dp) + val SheetPadding = 16.dp + val TileHeight = 88.dp + val FieldHeight = 48.dp + + /** The edge every floating pill surface draws, so it stays visible where shadows don't (black theme). */ + @Composable + fun hairline(): BorderStroke = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f)) +} + +/** + * A site's stand-in icon: its first letter on a tonal rounded square. Real favicons can drop in later + * (the launcher already stores them per host); the monogram keeps the header from ever being empty. + */ +@Composable +fun SiteMonogram( + label: String, + modifier: Modifier = Modifier, + size: Dp = 40.dp, +) { + val letter = label.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "•" + Box( + modifier + .size(size) + .clip(RoundedCornerShape(size * 0.3f)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + letter, + style = if (size >= 40.dp) MaterialTheme.typography.titleMedium else MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } +} + +/** + * The origin as a read-only field: security badge in its signal colour, host, and a trailing pencil that + * says "this is where the address lives". Tapping edits the address; long-pressing copies the link. + * Sandboxed apps get a non-editable version with no pencil. + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OriginField( + ui: BrowserPillUi, + onEdit: (() -> Unit)?, + onLongPress: (() -> Unit)?, + modifier: Modifier = Modifier, +) { + val security = ui.security + Row( + modifier + .fillMaxWidth() + .heightIn(min = PillDefaults.FieldHeight) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .combinedClickable( + enabled = onEdit != null || onLongPress != null, + role = Role.Button, + onClick = { onEdit?.invoke() }, + onLongClick = onLongPress, + ).padding(start = 16.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + 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, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + // Only the states worth a second look get words: plain HTTP and Tor. + if (!ui.chrome.isSandbox && (security == BrowserChrome.Security.HTTP || security == BrowserChrome.Security.TOR)) { + Text( + stringRes(securityLabel(security)), + style = MaterialTheme.typography.labelSmall, + color = securityTint(security), + maxLines = 1, + ) + } + } + if (onEdit != null) { + IconButton(onClick = onEdit) { + Icon(MaterialSymbols.Edit, contentDescription = stringRes(Res.string.browser_pill_edit_address), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } else { + Spacer(Modifier.width(10.dp)) + } + } +} + +/** + * The navigation capsule: Chrome's back · forward · reload/stop · star · share on one rounded bar. Back + * and forward dim when unavailable; the star fills when pinned; reload turns into stop, ringed by the load. + */ +@Composable +fun NavigationCapsule( + ui: BrowserPillUi, + onAction: (Action) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .fillMaxWidth() + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceContainerHigh) + .padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + BrowserChrome.iconRow(ui.chrome).forEach { action -> + val enabled = BrowserChrome.isEnabled(ui.chrome, action) + val pinned = action == Action.FAVORITE && ui.isFavorite + IconButton( + onClick = { onAction(action) }, + enabled = enabled, + colors = IconButtonDefaults.iconButtonColors(disabledContentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)), + ) { + Box(contentAlignment = Alignment.Center) { + if (action == Action.STOP && ui.loadProgress != null) { + CircularProgressIndicator( + progress = { ui.loadProgress }, + modifier = Modifier.size(34.dp), + strokeWidth = 2.dp, + trackColor = Color.Transparent, + ) + } + PillActionIcon( + action = action, + tint = + when { + !enabled -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f) + pinned -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.onSurface + }, + size = if (action == Action.STOP) 20.dp else 24.dp, + filled = pinned, + contentDescription = stringRes(pillLabelFor(action, ui.isFavorite)), + ) + } + } + } + } +} + +/** + * A page-action tile: icon over a two-line label on a rounded card. A toggle tile ([selected] non-null) + * fills with the secondary container while on, so its state reads without a switch. + */ +@Composable +fun ActionTile( + symbol: MaterialSymbol, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean? = null, + description: String = label, +) { + val on = selected == true + Surface( + onClick = onClick, + modifier = modifier.height(PillDefaults.TileHeight).semantics(mergeDescendants = true) { contentDescription = description }, + shape = PillDefaults.TileShape, + color = if (on) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surfaceContainer, + contentColor = if (on) MaterialTheme.colorScheme.onSecondaryContainer else MaterialTheme.colorScheme.onSurface, + ) { + Column( + // Top-aligned so every icon in a row sits on one line, whether its label takes one line or two. + Modifier.padding(start = 4.dp, end = 4.dp, top = 16.dp, bottom = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Icon(symbol, contentDescription = null, modifier = Modifier.size(24.dp), filled = on) + Spacer(Modifier.height(6.dp)) + Text( + label, + style = MaterialTheme.typography.labelMedium, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +/** A rounded group of rows (the privacy card), with an optional heading above it. */ +@Composable +fun GroupCard( + heading: String?, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Column(modifier.fillMaxWidth()) { + if (heading != null) { + Text( + heading, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(start = 12.dp, bottom = 6.dp), + ) + } + Surface(shape = PillDefaults.CardShape, color = MaterialTheme.colorScheme.surfaceContainer) { + Column(Modifier.padding(vertical = 4.dp), content = content) + } + } +} + +/** A divider inset past a [GroupRow]'s leading icon. */ +@Composable +fun GroupDivider() { + HorizontalDivider(Modifier.padding(start = 64.dp, end = 16.dp), color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) +} + +/** + * One row of a [GroupCard]: a tinted icon circle, a title with a supporting line that states the + * consequence, and a trailing control (switch, chevron, badge). + */ +@Composable +fun GroupRow( + icon: @Composable () -> Unit, + iconContainer: Color, + title: String, + supporting: String?, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + supportingColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, + trailing: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier + .fillMaxWidth() + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.size(40.dp).clip(CircleShape).background(iconContainer), contentAlignment = Alignment.Center) { icon() } + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis) + if (supporting != null) { + Text(supporting, style = MaterialTheme.typography.bodySmall, color = supportingColor, maxLines = 2, overflow = TextOverflow.Ellipsis) + } + } + Spacer(Modifier.width(8.dp)) + trailing() + } +} + +/** A small count badge (console errors). */ +@Composable +fun CountBadge( + count: Int, + container: Color = MaterialTheme.colorScheme.errorContainer, + content: Color = MaterialTheme.colorScheme.onErrorContainer, +) { + Box( + Modifier + .heightIn(min = 22.dp) + .clip(CircleShape) + .background(container) + .padding(horizontal = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text(if (count > 99) "99+" else count.toString(), style = MaterialTheme.typography.labelMedium, color = content) + } +} + +/** Dims content that is shown but not usable. */ +fun Modifier.dimmed(enabled: Boolean): Modifier = if (enabled) this else this.alpha(0.38f) diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt new file mode 100644 index 0000000000..abcb5c8ac7 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillIcons.kt @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.layout.size +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.PathParser +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Action +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome.Security +import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +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.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access +import com.vitorpamplona.amethyst.commons.resources.browser_pill_add_home +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back +import com.vitorpamplona.amethyst.commons.resources.browser_pill_back_to_app +import com.vitorpamplona.amethyst.commons.resources.browser_pill_console +import com.vitorpamplona.amethyst.commons.resources.browser_pill_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_desktop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_edit_address +import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_add +import com.vitorpamplona.amethyst.commons.resources.browser_pill_favorite_remove +import com.vitorpamplona.amethyst.commons.resources.browser_pill_find +import com.vitorpamplona.amethyst.commons.resources.browser_pill_forward +import com.vitorpamplona.amethyst.commons.resources.browser_pill_full_screen +import com.vitorpamplona.amethyst.commons.resources.browser_pill_other_browser +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_camera +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_location +import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_microphone +import com.vitorpamplona.amethyst.commons.resources.browser_pill_reload +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_http +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_https +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_sandbox +import com.vitorpamplona.amethyst.commons.resources.browser_pill_security_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_share +import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings +import com.vitorpamplona.amethyst.commons.resources.browser_pill_stop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_text_size +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_copy +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_desktop +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_find +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_full +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_home +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_other +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tile_text +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_title +import org.jetbrains.compose.resources.StringResource +import androidx.compose.material3.Icon as Material3Icon + +/** Tor's onion, as a vector (the same artwork as the Android `ic_tor` drawable). */ +val OnionIcon: ImageVector by lazy { + ImageVector + .Builder(name = "Onion", defaultWidth = 24.dp, defaultHeight = 24.dp, viewportWidth = 24f, viewportHeight = 24f) + .addPath(pathData = PathParser().parsePathString(ONION_PATH).toNodes(), fill = SolidColor(Color.Black)) + .build() +} + +private const val ONION_PATH = "M17.578,12.201c-0.76,-0.692 -1.721,-1.251 -2.704,-1.81 -0.446,-0.246 -1.81,-1.318 -1.34,-2.838l-0.851,-0.358c1.342,-2.078 3.085,-4.134 5.229,-6.056 -1.721,0.581 -3.24,1.476 -4.379,3.062 0.67,-1.407 1.765,-2.793 2.972,-4.201 -1.654,1.185 -3.084,2.525 -3.979,4.313l0.627,-2.503c-0.894,1.608 -1.52,3.24 -1.766,4.871l-1.317,-0.535 -0.223,0.178c1.162,2.078 0.559,3.174 -0.022,3.553 -1.162,0.783 -2.838,1.788 -3.688,2.659 -1.609,1.654 -2.078,3.218 -1.921,5.296 0.157,2.66 2.101,4.873 4.67,5.744 1.14,0.38 2.19,0.424 3.352,0.424 1.877,0 3.799,-0.491 5.207,-1.676a6.551,6.551 0,0 0,2.369 -5.027,6.875 6.875,0 0,0 -2.236,-5.096zM14.025,21.073c-0.09,0.402 -0.38,0.894 -0.737,1.341 0.134,-0.246 0.246,-0.492 0.313,-0.76 0.559,-1.989 0.805,-2.904 0.537,-5.095 -0.045,-0.224 -0.135,-0.938 -0.471,-1.721 -0.468,-1.185 -1.184,-2.303 -1.272,-2.548 -0.157,-0.38 -0.38,-1.989 -0.403,-3.084 0.023,0.938 0.089,2.659 0.335,3.329 0.067,0.225 0.715,1.229 1.185,2.459 0.312,0.849 0.38,1.632 0.446,1.854 0.224,1.007 -0.045,2.705 -0.401,4.313 -0.111,0.581 -0.426,1.252 -0.828,1.766 0.225,-0.313 0.402,-0.715 0.537,-1.185 0.269,-0.938 0.38,-2.145 0.356,-2.905 -0.021,-0.446 -0.222,-1.407 -0.558,-2.278 -0.201,-0.47 -0.492,-0.961 -0.692,-1.297 -0.224,-0.335 -0.224,-1.072 -0.313,-1.921 0.021,0.916 -0.068,1.385 0.156,2.033 0.134,0.379 0.625,0.916 0.759,1.43 0.201,0.693 0.402,1.453 0.381,1.922 0,0.536 -0.022,1.52 -0.269,2.593 -0.157,0.804 -0.515,1.497 -1.095,1.943 0.246,-0.312 0.38,-0.625 0.447,-0.938 0.089,-0.469 0.111,-0.916 0.156,-1.475a5.96,5.96 0,0 0,-0.111 -1.721c-0.179,-0.805 -0.469,-1.608 -0.604,-2.168 0.022,0.626 0.269,1.408 0.381,2.235 0.089,0.604 0.044,1.206 0.021,1.742 -0.021,0.627 -0.223,1.722 -0.492,2.258 -0.268,-0.112 -0.357,-0.269 -0.537,-0.491 -0.223,-0.291 -0.357,-0.604 -0.491,-0.962a5.043,5.043 0,0 1,-0.291 -0.915,3.071 3.071,0 0,1 0.559,-2.213c0.469,-0.671 0.559,-0.716 0.715,-1.497 -0.223,0.692 -0.379,0.759 -0.871,1.341 -0.559,0.647 -0.648,1.586 -0.648,2.346 0,0.313 0.134,0.671 0.246,1.007 0.134,0.356 0.268,0.714 0.447,0.982 0.134,0.223 0.313,0.379 0.469,0.491 -0.581,-0.156 -1.184,-0.379 -1.564,-0.692 -0.938,-0.805 -1.765,-2.167 -1.877,-3.375 -0.089,-0.982 0.804,-2.413 2.078,-3.128 1.073,-0.626 1.318,-1.319 1.542,-2.459 -0.313,0.983 -0.626,1.833 -1.654,2.348 -1.475,0.804 -2.235,2.1 -2.167,3.352 0.112,1.586 0.737,2.682 2.011,3.554 0.291,0.2 0.693,0.401 1.118,0.559 -1.587,-0.381 -1.788,-0.604 -2.324,-1.229 0,-0.045 -0.134,-0.135 -0.134,-0.156 -0.715,-0.805 -1.609,-2.19 -1.922,-3.464 -0.112,-0.447 -0.224,-0.916 -0.089,-1.363 0.581,-2.101 1.854,-2.905 3.128,-3.775 0.313,-0.225 0.626,-0.426 0.916,-0.649 0.715,-0.559 0.894,-2.012 1.05,-2.838 -0.29,1.006 -0.603,2.258 -1.162,2.659 -0.29,0.224 -0.648,0.402 -0.938,0.604 -1.318,0.894 -2.637,1.743 -3.24,3.91 -0.134,0.56 -0.044,0.962 0.089,1.498 0.335,1.317 1.229,2.748 1.989,3.597l0.134,0.135c0.335,0.381 0.76,0.67 1.274,0.871a5.945,5.945 0,0 1,-1.296 -0.469c-2.078,-1.005 -3.463,-3.173 -3.553,-4.939 -0.179,-3.597 1.542,-4.647 3.151,-5.966 0.894,-0.737 2.145,-1.095 2.86,-2.413 0.134,-0.291 0.224,-0.916 0.045,-1.587 -0.067,-0.224 -0.402,-1.028 -0.537,-1.207l1.989,0.872c-0.044,0.938 -0.067,1.698 0.112,2.391 0.2,0.76 1.184,1.854 1.586,3.129 0.783,2.41 0.583,5.561 0.023,8.019z" + +/** The glyph for [action]; null means it's drawn with [OnionIcon]. */ +fun pillSymbolFor(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.Shield + Action.SITE_SETTINGS -> MaterialSymbols.Tune + Action.CONSOLE -> MaterialSymbols.Code + } + +fun pillLabelFor( + action: Action, + isFavorite: Boolean = false, +): StringResource = + when (action) { + Action.BACK -> Res.string.browser_pill_back + Action.FORWARD -> Res.string.browser_pill_forward + Action.RELOAD -> Res.string.browser_pill_reload + Action.STOP -> Res.string.browser_pill_stop + Action.FAVORITE -> if (isFavorite) Res.string.browser_pill_favorite_remove else Res.string.browser_pill_favorite_add + Action.SHARE -> Res.string.browser_pill_share + Action.BACK_TO_APP -> Res.string.browser_pill_back_to_app + Action.COPY_LINK -> Res.string.browser_pill_copy + Action.EDIT_ADDRESS -> Res.string.browser_pill_edit_address + Action.FIND_IN_PAGE -> Res.string.browser_pill_find + Action.TEXT_SIZE -> Res.string.browser_pill_text_size + Action.DESKTOP_SITE -> Res.string.browser_pill_desktop + Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_add_home + Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_other_browser + Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_full_screen + Action.TOR -> Res.string.browser_pill_tor_title + Action.ACCESS_INFO -> Res.string.browser_pill_access + Action.SITE_SETTINGS -> Res.string.browser_pill_site_settings + Action.CONSOLE -> Res.string.browser_pill_console + } + +/** + * The short label a page-action tile shows under its icon (tiles are ~88dp wide); the full + * [pillLabelFor] wording stays the accessibility description. + */ +fun pillTileLabelFor(action: Action): StringResource = + when (action) { + Action.COPY_LINK -> Res.string.browser_pill_tile_copy + Action.FIND_IN_PAGE -> Res.string.browser_pill_tile_find + Action.TEXT_SIZE -> Res.string.browser_pill_tile_text + Action.DESKTOP_SITE -> Res.string.browser_pill_tile_desktop + Action.ADD_TO_HOME_SCREEN -> Res.string.browser_pill_tile_home + Action.OPEN_IN_BROWSER_APP -> Res.string.browser_pill_tile_other + Action.OPEN_FULL_SCREEN -> Res.string.browser_pill_tile_full + else -> pillLabelFor(action) + } + +fun securityLabel(security: Security): StringResource = + when (security) { + Security.TOR -> Res.string.browser_pill_security_tor + Security.HTTPS -> Res.string.browser_pill_security_https + Security.HTTP -> Res.string.browser_pill_security_http + Security.SANDBOX -> Res.string.browser_pill_security_sandbox + } + +fun permissionLabel(permission: BrowserSitePermission): StringResource = + when (permission) { + BrowserSitePermission.CAMERA -> Res.string.browser_pill_permission_camera + BrowserSitePermission.MICROPHONE -> Res.string.browser_pill_permission_microphone + BrowserSitePermission.LOCATION -> Res.string.browser_pill_permission_location + } + +fun permissionSymbol(permission: BrowserSitePermission): MaterialSymbol = + when (permission) { + BrowserSitePermission.CAMERA -> MaterialSymbols.Videocam + BrowserSitePermission.MICROPHONE -> MaterialSymbols.Mic + BrowserSitePermission.LOCATION -> MaterialSymbols.LocationOn + } + +/** The colour that signals [security]: the error tone for plain HTTP, the Tor accent for onion routing. */ +@Composable +fun securityTint(security: Security): Color = + when (security) { + Security.HTTP -> MaterialTheme.colorScheme.error + Security.TOR -> MaterialTheme.colorScheme.tertiary + Security.SANDBOX -> MaterialTheme.colorScheme.primary + Security.HTTPS -> MaterialTheme.colorScheme.onSurfaceVariant + } + +/** Draws [action]'s icon (the onion for Tor). */ +@Composable +fun PillActionIcon( + action: Action, + tint: Color, + size: Dp = 24.dp, + filled: Boolean = false, + contentDescription: String? = null, +) { + val symbol = pillSymbolFor(action) + if (symbol != null) { + Icon(symbol, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint, filled = filled) + } else { + Material3Icon(OnionIcon, contentDescription = contentDescription, modifier = Modifier.size(size), tint = tint) + } +} + +/** Draws the badge for [security] in its signal colour. */ +@Composable +fun SecurityIcon( + security: Security, + size: Dp = 18.dp, + tint: Color = securityTint(security), +) { + when (security) { + Security.TOR -> Material3Icon(OnionIcon, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.HTTPS -> Icon(MaterialSymbols.Lock, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.HTTP -> Icon(MaterialSymbols.NoEncryption, contentDescription = null, modifier = Modifier.size(size), tint = tint) + Security.SANDBOX -> Icon(MaterialSymbols.Shield, contentDescription = null, modifier = Modifier.size(size), tint = tint, filled = true) + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt index 595b7cf845..6920115b06 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/symbols/MaterialSymbols.kt @@ -77,11 +77,13 @@ object MaterialSymbols { val CloudSync = MaterialSymbol("\uEB5A") val CloudUpload = MaterialSymbol("\uE2C3") val Code = MaterialSymbol("\uE86F") + val Cookie = MaterialSymbol("\uEAAC") val Collections = MaterialSymbol("\uE3D3") val CollectionsBookmark = MaterialSymbol("\uE431") val Commit = MaterialSymbol("\uEAF5") val ContentCopy = MaterialSymbol("\uE14D") val ContentPaste = MaterialSymbol("\uE14F") + val ContentPasteGo = MaterialSymbol("\uEA8E") val CurrencyBitcoin = MaterialSymbol("\uEBC5") val Dashboard = MaterialSymbol("\uE871") val DateRange = MaterialSymbol("\uE916") @@ -173,6 +175,7 @@ object MaterialSymbols { val News = MaterialSymbol("\uE032") val NoAccounts = MaterialSymbol("\uF03E") val NoEncryption = MaterialSymbol("\uF03F") + val NorthWest = MaterialSymbol("\uF1E2", autoMirror = true) val Notifications = MaterialSymbol("\uE7F5") val NotificationsOff = MaterialSymbol("\uE7F6") val Numbers = MaterialSymbol("\uEAC7") diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt new file mode 100644 index 0000000000..3ac0d504e9 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -0,0 +1,96 @@ +/* + * 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 androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.unit.Density +import org.jetbrains.skia.EncodedImageFormat +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Renders every browser-pill prototype offscreen (no device, no display) through `ImageComposeScene`, in the + * dark and light Amethyst themes side by side, and writes each to `commonsUI/build/browser-pill/<name>.png`. + * + * It exists so the redesign can be *looked at* while it's built — see + * `amethyst/plans/2026-09-26-browser-ui-review.md` — and it fails if a screen throws or draws nothing. + */ +class BrowserPillRenderTest { + private val outDir = File("build/browser-pill").apply { mkdirs() } + + private fun render( + name: String, + widthDp: Int, + heightDp: Int, + content: @Composable () -> Unit, + ) { + val density = 2f + val width = (widthDp * density).toInt() + val height = (heightDp * density).toInt() + val scene = ImageComposeScene(width = width, height = height, density = Density(density), content = content) + try { + // Strings and fonts load asynchronously from compose resources: let a few frames settle. + var image = scene.render(0) + repeat(SETTLE_FRAMES) { frame -> + Thread.sleep(FRAME_MILLIS) + image = scene.render((frame + 1) * FRAME_MILLIS * 1_000_000L) + } + val png = image.encodeToData(EncodedImageFormat.PNG) ?: error("could not encode $name") + File(outDir, "$name.png").writeBytes(png.bytes) + + val pixels = image.peekPixels() ?: error("no pixels for $name") + val distinct = HashSet<Int>() + for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y) + assertTrue(distinct.size > 20, "$name rendered almost nothing (${distinct.size} colours)") + } finally { + scene.close() + } + } + + @Test fun expanded() = render("01-expanded", 820, 1000) { BrowserPillExpandedPreview() } + + @Test fun torOutOfScope() = render("02-tor-out-of-scope", 820, 1100) { BrowserPillTorOutOfScopePreview() } + + @Test fun addressEditor() = render("03-address-editor", 820, 700) { BrowserPillAddressEditorPreview() } + + @Test fun napplet() = render("04-napplet", 820, 800) { BrowserPillNappletPreview() } + + @Test fun handles() = render("05-handles", 820, 160) { PillHandlesPreview() } + + @Test fun find() = render("06-find", 820, 220) { FindInPagePreview() } + + @Test fun console() = render("07-console", 820, 380) { ConsoleSheetPreview() } + + @Test fun permission() = render("08-permission", 820, 700) { PermissionPromptPreview() } + + @Test fun dialog() = render("09-dialog", 820, 560) { PageDialogPreview() } + + @Test fun leave() = render("10-leave", 820, 420) { LeaveSiteDialogPreview() } + + @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() } + + private companion object { + const val SETTLE_FRAMES = 12 + const val FRAME_MILLIS = 60L + } +} From d52d54ac3f1f74c95c99f761fa1cad7c2d19680a Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 18:36:00 +0000 Subject: [PATCH 09/14] 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 <noreply@anthropic.com> 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: "<host> wants to — use your camera — Block / Allow". - * Dismissing it denies this once without remembering anything. - */ -@Composable -fun EmbeddedPermissionPrompt( - origin: String, - permissions: Set<BrowserSitePermission>, - 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<ConsoleLogEntry>() + override val consoleLogs = mutableStateListOf<ConsoleLine>() 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<EmbeddedPermissionRequest?>(null) - /** Page-info text for the page on screen, once requested. */ - val pageInfo = mutableStateOf<String?>(null) + /** The certificate of the page on screen, once page info asked for it (null for none, or not yet). */ + val pageCertificate = mutableStateOf<CertificateInfo?>(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<ConsoleLogEntry>, - 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<ConsoleLogEntry> + val consoleLogs: SnapshotStateList<ConsoleLine> 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<AddressSuggestion> = { 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<String>) { } } - // 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 @@ <string name="browser_file_chooser_title">Choose a file</string> <!-- Shown when the device has no app that can hand a file back to the page. --> <string name="browser_file_chooser_unavailable">No app available to pick a file</string> - <!-- Browser top pill (shared by the embedded tab and the full-screen browser) --> - <string name="browser_action_back">Back</string> - <string name="browser_action_forward">Forward</string> - <string name="browser_action_reload">Reload</string> - <string name="browser_action_stop">Stop loading</string> - <string name="browser_action_favorite_add">Add to favorites</string> - <string name="browser_action_favorite_remove">Remove from favorites</string> - <string name="browser_action_share">Share…</string> - <string name="browser_action_back_to_app">Back to %1$s</string> - <string name="browser_action_copy_link">Copy link</string> - <string name="browser_action_edit_address">Edit address</string> - <string name="browser_action_find_in_page">Find in page</string> - <string name="browser_action_text_size">Text size</string> - <string name="browser_action_text_size_value">%1$d%%</string> - <string name="browser_action_text_smaller">Smaller text</string> - <string name="browser_action_text_larger">Larger text</string> - <string name="browser_action_desktop_site">Desktop site</string> - <string name="browser_action_add_to_home">Add to Home screen</string> - <string name="browser_action_open_in_browser_app">Open in another browser</string> - <string name="browser_action_open_full_screen">Open full screen</string> - <string name="browser_action_tor_on">Loads over Tor</string> - <string name="browser_action_tor_off">Loads over the open web</string> - <string name="browser_action_access_info">What it can access</string> - <string name="browser_action_site_settings">Site settings & permissions</string> - <string name="browser_action_close">Close</string> - <string name="browser_section_privacy">Privacy</string> - <string name="browser_section_developer">Developer</string> - <string name="browser_security_tor">Onion-routed over Tor</string> - <string name="browser_security_https">Secure connection</string> - <string name="browser_security_http">Not secure</string> - <string name="browser_security_sandbox">Sandboxed app</string> + <!-- Browser actions --> <string name="browser_link_copied">Link copied</string> <string name="browser_no_other_browser">No other browser installed</string> - <!-- Find in page --> - <string name="browser_find_hint">Find in page</string> - <string name="browser_find_count">%1$d/%2$d</string> - <string name="browser_find_previous">Previous match</string> - <string name="browser_find_next">Next match</string> - <string name="browser_find_close">Close find in page</string> - - <!-- Page-originated JavaScript dialogs (alert / confirm / prompt / beforeunload) --> - <string name="browser_js_dialog_title">%1$s says</string> - <string name="browser_js_dialog_title_generic">This page says</string> - <string name="browser_js_dialog_block">Block dialogs from this page</string> - <string name="browser_js_leave_title">Leave site?</string> - <string name="browser_js_leave_message">Changes you made may not be saved.</string> - <string name="browser_js_leave">Leave</string> - <!-- Downloads and sharing --> <string name="browser_download_started">Downloading %1$s…</string> <string name="browser_download_saved">Saved %1$s to Downloads</string> <string name="browser_download_failed">Couldn\'t download %1$s</string> <string name="browser_share_chooser">Share</string> - <!-- Page info --> - <string name="browser_page_info_title">Page info</string> - <string name="browser_page_info_https">Your connection to this site is encrypted.</string> - <string name="browser_page_info_http">Your connection to this site is not encrypted. Don\'t enter passwords or payment details.</string> - <string name="browser_page_info_tor">This site loads over Tor. The site can\'t see your IP address.</string> - <string name="browser_page_info_open_web">This site loads over the open web. The site can see your IP address.</string> - <string name="browser_page_info_certificate">Certificate issued to %1$s by %2$s, valid until %3$s.</string> - <string name="browser_page_info_permissions">Permissions</string> - <string name="browser_page_info_clear_data">Clear site data</string> <string name="browser_site_data_cleared">Site data cleared</string> <!-- Site permissions (camera / microphone / location) --> - <string name="browser_permission_title">%1$s wants to</string> <string name="browser_permission_camera">Use your camera</string> <string name="browser_permission_microphone">Use your microphone</string> <string name="browser_permission_location">Know your approximate location</string> - <string name="browser_permission_allow">Allow</string> - <string name="browser_permission_block">Block</string> <string name="browser_permission_system_denied">Amethyst doesn\'t have Android\'s permission for this. Allow it in system settings.</string> <string name="browser_permission_ask">Ask</string> <string name="browser_permission_allowed">Allowed</string> 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<AddressSuggestion>, + suggestionsFor: (String) -> List<AddressSuggestion>, 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<AddressSuggestion> = emptyList(), + suggestionsFor: (String) -> List<AddressSuggestion> = { 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<AddressSuggestion> = emptyList(), + suggestionsFor: (String) -> List<AddressSuggestion> = { 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<AddressSuggestion> = { 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<BrowserSitePermission>, + 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<Int?>(null) + + var consoleShowing by mutableStateOf(false) + private set + val console = mutableStateListOf<ConsoleLine>() + + var dialog by mutableStateOf<PendingDialog?>(null) + var permissionPrompt by mutableStateOf<PendingPermission?>(null) + private var pageInfoOpen by mutableStateOf(false) + private var certificate by mutableStateOf<CertificateInfo?>(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<String>() - 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 (`<input type="file">`) ---- // 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<Long, (Map<BrowserSitePermission, Decision>) -> Unit>() private var sitePermissionSeq = 0L - private var permissionPrompt: AlertDialog? = null private var pendingRuntimeGrant: ((Map<String, Boolean>) -> 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: "<host> 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<BrowserSitePermission>, - 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 `<title>`, 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 From 9d0dd96de705098fad9e34e844335f6d7de0a897 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 17:41:08 -0400 Subject: [PATCH 10/14] fix(browser): the two crashes that make the new chrome unusable on a device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reproduce on the first page you open. Neither is visible to the build: lint, Android Lint, the metadata compiles, the native compile and the whole test suite are green with both present. **Any page with a favicon killed the full-screen browser.** `TaskDescription.Builder.setIcon(Icon)` exists from API **37**; below that the Builder only takes a drawable resource id. We compile against 37, so it resolves, and the guard was `SDK_INT >= TIRAMISU` — so Android 13, 14, 15 and 16 all called a method their framework does not have: NoSuchMethodError: No virtual method setIcon(Landroid/graphics/drawable/Icon;) at NappletBrowserActivity.updateTaskDescription(...:1186) at BrowserChromeClient.onReceivedIcon(...:520) The `runCatching` there wraps `setTaskDescription`, not the building, so it caught nothing. Gated on 37; everything below keeps the deprecated constructor, which takes the same three things and carries the bitmap anyway. Android Lint's NewApi did not flag this, which is worth knowing: compiling against a preview SDK makes its whole surface look available. **Opening the pill killed the sandbox.** The chrome is now drawn by shared composables that read `Res.string`, and those run in `:napplet`: MissingResourceException: ... Android context is not initialized. Compose Resources learns its Context from a ContentProvider the library declares, and a provider is only instantiated in the process that owns it. Three things do not work here and are worth recording so they are not retried: a second `<provider>` element (the manifest merger keys them by `android:name` and merges the two into one), a subclass with its own authority (`AndroidContextProvider` is `internal` *and* final), and `PreviewContextConfigurationEffect()`, which the exception text suggests but which sets the Context from an effect that runs *after* composition — while `stringRes` starts its async load during it. What works is the pair: `android:multiprocess="true"` lets each process hold its own instance, and acquiring the provider once in the sandbox's two activities is the first access that makes Android create it. After that every lookup resolves in-process, with no IPC. `multiprocess` alone is not enough — the instance is lazy, and nothing in `:napplet` ever addresses that authority. Verified on an SM-T220 (API 34): the full-screen browser loads a page, and the pill opens with every string, the Tor state and the site-settings summary rendered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- amethyst/src/main/AndroidManifest.xml | 16 +++++ .../napplethost/NappletBrowserActivity.kt | 13 +++- .../napplethost/NappletHostActivity.kt | 1 + .../napplethost/SandboxComposeResources.kt | 59 +++++++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index b120f60ad1..dd42a938dd 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -640,6 +640,22 @@ android:name=".service.call.CallNotificationReceiver" android:exported="false" /> + <!-- + Compose Resources learns its Context from a ContentProvider the library + declares, and a provider is instantiated only in the process it belongs + to. The browser chrome is now drawn by shared composables that read + `Res.string`, and those run in `:napplet` — where the lookup threw + MissingResourceException ("Android context is not initialized") and killed + the sandbox. `multiprocess` is the platform's answer to exactly this: it + gives every process of the app its own instance of the provider. A second + <provider> element cannot work, because the manifest merger keys them by + android:name and would merge the two into one. + --> + <provider + android:name="org.jetbrains.compose.resources.AndroidContextProvider" + android:multiprocess="true" + tools:node="merge" /> + <!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. --> <activity android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity" 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 da634a5ecf..558c750bb7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -268,6 +268,7 @@ class NappletBrowserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + SandboxComposeResources.ensure(this) // A new window a page opened: its WebView already exists (built inside the opener's onCreateWindow). val popupToken = intent.getStringExtra(EXTRA_POPUP_TOKEN) @@ -1178,8 +1179,15 @@ class NappletBrowserActivity : ComponentActivity() { private fun updateTaskDescription() { val label = pageTitle ?: title.ifBlank { null } ?: BrowserChrome.displayHost(currentUrl()) val color = themeColor ?: 0 + // The Builder's `setIcon` that takes an `Icon` is API 37. We compile + // against 37, so it resolves, and the old guard was `TIRAMISU` — which + // meant every device from 33 to 36 called a method its framework does + // not have and died with NoSuchMethodError the moment a page delivered + // a favicon. Lint's NewApi did not flag it. The deprecated constructor + // takes the same three things and carries the bitmap, so it stays the + // path for everything below 37. val description = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (Build.VERSION.SDK_INT >= ICON_BUILDER_SDK) { ActivityManager.TaskDescription .Builder() .setLabel(label) @@ -1637,6 +1645,9 @@ class NappletBrowserActivity : ComponentActivity() { private const val EXTRA_IS_FAVORITE = "isFavorite" private const val EXTRA_POPUP_TOKEN = "popupToken" + /** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */ + private const val ICON_BUILDER_SDK = 37 + fun intent( context: Context, url: String, diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index b16ab84463..b8f57894a0 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -259,6 +259,7 @@ class NappletHostActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + SandboxComposeResources.ensure(this) if (!readManifestExtras()) { Toast.makeText(this, getString(R.string.napplet_invalid), Toast.LENGTH_SHORT).show() diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt new file mode 100644 index 0000000000..f0688d8af6 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/SandboxComposeResources.kt @@ -0,0 +1,59 @@ +/* + * 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 com.vitorpamplona.quartz.utils.Log + +/** + * Gives this process the Context that Compose Resources needs. + * + * The browser chrome is drawn by shared composables that read `Res.string`, and + * they run here, in `:napplet`. Compose Resources learns its Context from a + * ContentProvider the library declares, and a provider is only instantiated in + * the process that owns it — so every string lookup in the sandbox died with + * MissingResourceException, taking the window with it. + * + * `android:multiprocess="true"` (set on that provider in the app manifest) lets + * each process hold its own instance, but Android creates it lazily, on first + * access. Nothing in `:napplet` ever addresses the provider by authority, so + * without this it is never created. Acquiring a client once is that first + * access; the provider's `onCreate` then records this process's Context and + * every later lookup resolves locally, with no IPC. + * + * Call before anything composes. It is cheap and idempotent. + */ +object SandboxComposeResources { + private var done = false + + fun ensure(context: Context) { + if (done) return + done = true + val authority = "${context.packageName}.resources.AndroidContextProvider" + runCatching { + context.contentResolver.acquireContentProviderClient(authority)?.close() + }.onFailure { + // Not fatal on its own: the failure surfaces later as a missing + // string, which is easier to read with this line above it. + Log.w("SandboxComposeResources", "could not warm $authority: ${it.message}") + } + } +} From a930fc97f883d764b38682d22da0e20802785251 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 18:02:53 -0400 Subject: [PATCH 11/14] design(browser): let the collapsed handle go quiet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The handle is on screen the whole time a page is, and almost nobody pulls it down — it is there for when you are stuck on a site, not as a status display. Tinting it with the Tor accent lit it up on every page, because onion routing is the normal case here rather than the exception, and an accent that is always on is one nobody reads. So it spends colour only on what a reader should act on: plain HTTP keeps the error colour, the console-error dot stays (5dp at 80%, down from a solid 7dp), and everything else — Tor included — is the same muted grey. Tor is not hidden; it keeps its badge inside the pill, where the address row says "Onion-routed" in words for anyone who opens it. I also tried dropping the handle's background, and put it back: on a dark page it nearly vanished, and the point is to call less attention, not to be undiscoverable. `BrowserPillRenderTest` needed a note. Its "did anything render" guard is a flat `distinct colours > 20` over the whole canvas, and `05-handles` — three small bars on mostly empty space — fell to 18 once the accent came off. That guard was in effect asserting the handle has a saturated accent, which is the thing being removed. Rather than lower it for all eleven images, that one gets `minColours = 12`: a blank render scores 1-3, so it still catches the failure the guard exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../commons/browser/ui/pill/BrowserPill.kt | 20 ++++++++++++------- .../browser/ui/pill/BrowserPillRenderTest.kt | 10 ++++++++-- 2 files changed, 21 insertions(+), 9 deletions(-) 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 3c261ee149..2c93718755 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 @@ -141,9 +141,16 @@ fun BrowserPill( } /** - * The collapsed grabber. It tells the page's story before it's opened: the bar takes the error colour on - * plain HTTP and the Tor accent when onion-routed, a hairline fills while the page loads, and a dot - * appears when the console has errors. + * The collapsed grabber. + * + * It is on screen the whole time a page is, and almost nobody pulls it down: it is there for when you + * are stuck on a site, not as a status display. So it stays quiet, and spends colour only on the states + * a reader should act on. + * + * Onion routing is not one of them. It is the normal case here rather than an exception, and an accent + * that is always lit is one nobody reads — it just makes the handle loud on every page. Tor keeps its + * badge inside the pill, where the address and "Onion-routed" say it in words for anyone who opens it. + * What is left in the handle is plain HTTP, which is a warning, and a dot for console errors. */ @Composable fun PillHandle( @@ -155,8 +162,7 @@ fun PillHandle( val barColor = when (ui.security) { BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error - BrowserChrome.Security.TOR -> MaterialTheme.colorScheme.tertiary - else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) } Box( modifier @@ -200,9 +206,9 @@ fun PillHandle( Modifier .align(Alignment.CenterEnd) .padding(start = 44.dp) - .size(7.dp) + .size(5.dp) .clip(CircleShape) - .background(MaterialTheme.colorScheme.error), + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.8f)), ) } } diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt index 3ac0d504e9..d449a33afb 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -42,6 +42,7 @@ class BrowserPillRenderTest { name: String, widthDp: Int, heightDp: Int, + minColours: Int = 20, content: @Composable () -> Unit, ) { val density = 2f @@ -61,7 +62,7 @@ class BrowserPillRenderTest { val pixels = image.peekPixels() ?: error("no pixels for $name") val distinct = HashSet<Int>() for (y in 0 until height step 7) for (x in 0 until width step 7) distinct += pixels.getColor(x, y) - assertTrue(distinct.size > 20, "$name rendered almost nothing (${distinct.size} colours)") + assertTrue(distinct.size > minColours, "$name rendered almost nothing (${distinct.size} colours)") } finally { scene.close() } @@ -75,7 +76,12 @@ class BrowserPillRenderTest { @Test fun napplet() = render("04-napplet", 820, 800) { BrowserPillNappletPreview() } - @Test fun handles() = render("05-handles", 820, 160) { PillHandlesPreview() } + // Three small bars on a mostly empty canvas, and deliberately the quietest thing + // the redesign draws — so it clears the "did anything render" bar by less than the + // full screens do. It scored 18 when the accent came off; a blank render is 1-3, so + // 12 still catches the failure this guard is for without demanding a colour the + // component is not supposed to have. + @Test fun handles() = render("05-handles", 820, 160, minColours = 12) { PillHandlesPreview() } @Test fun find() = render("06-find", 820, 220) { FindInPagePreview() } From dc5932a8121801f023389db8cf5c83b850cd158d Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 18:26:22 -0400 Subject: [PATCH 12/14] feat(browser): name the browser the hand-off tile will open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Other browser" says what it is not. When the system will name a default, the tile now says "Open in Chrome" — which is also the answer to "where does this go?", the question the tile exists to answer. Knowing the name took a `<queries>` entry. Android 11+ package visibility answers `resolveActivity` with nothing for a scheme the manifest does not declare, so the app could see every payment scheme it had asked about and not https. One entry alongside those, read-only: it names the target, it does not change where anything goes. `defaultBrowserName` is null whenever a name would be a promise we cannot keep — no default set (the system resolves to its own picker, which would otherwise be labelled "Android System"), the only handler is Amethyst, or visibility still hides it. The tile falls back to "Open in browser" and the hand-off keeps going through a chooser either way, so a wrong answer here costs a label and never a mis-launch. Only the full-screen browser passes a name so far; the embedded tab still shows the generic label. Verified on an SM-T220: the tile reads "Open in Chrome", on one line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- amethyst/src/main/AndroidManifest.xml | 11 +++ .../composeResources/values/strings.xml | 1 + .../commons/browser/ui/pill/BrowserPill.kt | 9 ++- .../browser/ui/pill/BrowserPillModel.kt | 7 ++ .../amethyst/napplethost/DefaultBrowser.kt | 69 +++++++++++++++++++ .../napplethost/NappletBrowserActivity.kt | 1 + 6 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/DefaultBrowser.kt diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index dd42a938dd..d2b9dabc65 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -17,6 +17,17 @@ <action android:name="android.intent.action.TTS_SERVICE" /> </intent> + <!-- So the browser's hand-off tile can name the browser it would open. + Android 11+ package visibility answers resolveActivity with nothing + for a scheme that is not declared here, so without this the tile can + only ever say "Open in browser". Read-only: it names the target, the + hand-off itself still goes through a chooser. --> + <intent> + <action android:name="android.intent.action.VIEW" /> + <category android:name="android.intent.category.BROWSABLE" /> + <data android:scheme="https" /> + </intent> + <!-- NIP-A3 payment targets. Android 11+ package visibility means queryIntentActivities returns NOTHING for a scheme not declared here, so without these the zap picker's pay-to chip is invisible on every diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d1e8fac7c4..206367ecd7 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5374,6 +5374,7 @@ <string name="browser_pill_add_home">Add to Home</string> <string name="browser_pill_desktop">Desktop site</string> <string name="browser_pill_other_browser">Open in browser</string> + <string name="browser_pill_other_browser_named">Open in %1$s</string> <string name="browser_pill_full_screen">Full screen</string> <string name="browser_pill_left_site">You left %1$s</string> <string name="browser_pill_back_to_app">Back to app</string> 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 2c93718755..ca09cc1bc8 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 @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.commons.resources.browser_pill_close import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_allowed import com.vitorpamplona.amethyst.commons.resources.browser_pill_decision_blocked import com.vitorpamplona.amethyst.commons.resources.browser_pill_left_site +import com.vitorpamplona.amethyst.commons.resources.browser_pill_other_browser_named import com.vitorpamplona.amethyst.commons.resources.browser_pill_permission_state import com.vitorpamplona.amethyst.commons.resources.browser_pill_privacy import com.vitorpamplona.amethyst.commons.resources.browser_pill_site_settings_none @@ -365,10 +366,14 @@ private fun TileGrid( actions.chunked(columns).forEach { rowActions -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { rowActions.forEach { action -> + // The hand-off tile names the browser it will actually open when the + // system will name one; "Open in browser" is what is left when the + // answer is a chooser. See BrowserPillUi.defaultBrowserName. + val named = ui.defaultBrowserName?.takeIf { action == Action.OPEN_IN_BROWSER_APP } ActionTile( symbol = pillSymbolFor(action) ?: MaterialSymbols.Info, - label = stringRes(pillTileLabelFor(action)), - description = stringRes(pillLabelFor(action)), + label = named?.let { stringRes(Res.string.browser_pill_other_browser_named, it) } ?: stringRes(pillTileLabelFor(action)), + description = named?.let { stringRes(Res.string.browser_pill_other_browser_named, it) } ?: stringRes(pillLabelFor(action)), onClick = { onAction(action) }, selected = when (action) { 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 1d3ea5b87a..e4ef87c8ee 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 @@ -44,6 +44,13 @@ data class BrowserPillUi( val consoleErrors: Int = 0, /** Answers this site already has (camera / mic / location), for the site-settings summary. */ val sitePermissions: Map<BrowserSitePermission, BrowserSitePermission.Decision> = emptyMap(), + /** + * The default browser's name, when the system will name one and it is not us. + * + * Null means the hand-off shows a chooser, and the tile has to stay "Open in browser": + * no default is set, the device hides it, or the only handler is Amethyst itself. + */ + val defaultBrowserName: String? = null, ) { val security: BrowserChrome.Security get() = BrowserChrome.security(chrome) val host: String get() = BrowserChrome.displayHost(chrome.url) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/DefaultBrowser.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/DefaultBrowser.kt new file mode 100644 index 0000000000..a15b5b7d40 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/DefaultBrowser.kt @@ -0,0 +1,69 @@ +/* + * 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.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ResolveInfo +import androidx.core.net.toUri + +/** + * Who "open in a browser" would actually hand the page to. + * + * Only used to say so on the tile. The hand-off itself still goes through a chooser, so a wrong + * or missing answer here costs a label, never a mis-launch. + */ +object DefaultBrowser { + /** A probe URL: the scheme is what selects browsers, the host is never contacted. */ + private val PROBE = Intent(Intent.ACTION_VIEW, "https://example.com".toUri()).addCategory(Intent.CATEGORY_BROWSABLE) + + /** + * The default browser's app label, or null when the tile should stay generic. + * + * Null in three cases that all mean the same thing to a reader — you are going to get a + * chooser, so do not promise a name: + * - no default is set, and the system resolves to its own picker; + * - the only handler is Amethyst, so "open in a browser" means anything but us; + * - package visibility hides it. Android 11+ answers `resolveActivity` with nothing unless + * the manifest declares a matching `<queries>` entry, which is why one exists for + * http/https alongside the payment schemes. + */ + fun label(context: Context): String? = + runCatching { + val pm = context.packageManager + + @Suppress("DEPRECATION") + val match: ResolveInfo = pm.resolveActivity(PROBE, PackageManager.MATCH_DEFAULT_ONLY) ?: return null + val pkg = match.activityInfo?.packageName ?: return null + // The system picker resolves for everything; naming it would be a lie. + if (pkg == context.packageName || isResolver(match)) return null + match.loadLabel(pm).toString().takeIf { it.isNotBlank() } + }.getOrNull() + + /** + * Whether this is Android's own chooser rather than a browser. + * + * `resolveActivity` returns the resolver when several apps match and none is default; it + * reports `exported=false` from the `android` package, which is the cheap way to tell. + */ + private fun isResolver(info: ResolveInfo): Boolean = info.activityInfo?.packageName == "android" +} 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 558c750bb7..a32f8f27d1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -1337,6 +1337,7 @@ class NappletBrowserActivity : ComponentActivity() { torOn = if (proxyPort > 0) useTor else null, ), isFavorite = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), + defaultBrowserName = DefaultBrowser.label(this), ), listener = chromeListener, ) From b1849e5880be81634f27742b5ca6b31ea15f7b6b Mon Sep 17 00:00:00 2001 From: Claude <noreply@anthropic.com> Date: Sat, 26 Sep 2026 22:52:20 +0000 Subject: [PATCH 13/14] feat(browser): bring embedded nSites and nApplets to parity with full screen - An embedded nSite is shown as an nSite with the Tor row. Switching the route saves it and rebuilds the session, as the full-screen host relaunches. - "What it can access" is a new AccessInfoSheet card in both the embedded tab and the full-screen window, replacing the platform dialogs. - The embedded tab gets find in page, text size and the developer console through new NappletEmbedContract messages served by NappletHostService. Also removes nappletHost strings left unused by the Compose chrome. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TEkj7Eo2xbidVHAoF8GZKQ --- .../plans/2026-09-26-browser-ui-review.md | 15 +- .../browser/EmbeddedWebAppController.kt | 11 +- .../ui/screen/loggedIn/embed/ConsoleBridge.kt | 10 ++ .../favorites/EmbeddedNostrAppController.kt | 47 ++++- .../loggedIn/favorites/NostrAppScreen.kt | 89 +++++----- .../composeResources/values/strings.xml | 6 + .../browser/ui/pill/AccessInfoSheet.kt | 164 ++++++++++++++++++ .../browser/ui/pill/BrowserPillPreviews.kt | 17 ++ .../browser/ui/pill/BrowserPillRenderTest.kt | 2 + .../amethyst/napplethost/BrowserChromeHost.kt | 37 ++++ .../napplethost/NappletEmbedContract.kt | 28 +++ .../napplethost/NappletHostActivity.kt | 28 ++- .../napplethost/NappletHostService.kt | 68 ++++++++ nappletHost/src/main/res/values/strings.xml | 21 +-- 14 files changed, 447 insertions(+), 96 deletions(-) create mode 100644 commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt diff --git a/amethyst/plans/2026-09-26-browser-ui-review.md b/amethyst/plans/2026-09-26-browser-ui-review.md index f87c24a31b..2797131d3d 100644 --- a/amethyst/plans/2026-09-26-browser-ui-review.md +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -249,8 +249,13 @@ is gone. `EmbeddedPageDialogs`, `ConsoleLogEntry` and `BrowserWebTools.pageInfo`, plus the Android strings only they used. -Still open: -- The sandboxed apps' "What it can access" is still a platform `AlertDialog`. It lists launch - capabilities, not browser state. -- The embedded nsite/napplet tab has no find or text-size plumbing yet (`hasFind` / `hasTextSize` are - off there). +**Sandboxed apps, embedded and full screen alike:** +- An embedded nSite is labelled as an nSite, not "Sandboxed app", and gets the Tor row. Switching the + route saves it in `NappletNetworkRegistry` and rebuilds the session, as the full-screen host relaunches. +- "What it can access" is `AccessInfoSheet`: the launch capabilities, the keys-stay-in-Amethyst row, the + route (nSites), and Manage permissions. It replaces both the platform `AlertDialog` and the embedded + tab's `AccessDialog`. +- The embedded tab has find, text size and the console. `NappletEmbedContract` gained `MSG_FIND`, + `MSG_FIND_NEXT`, `MSG_FIND_RESULT`, `MSG_SET_TEXT_ZOOM` and `MSG_CONSOLE_LOG`, which + `NappletHostService` serves the same way `NappletBrowserService` does. Load and HTTP errors show up as + console errors, as they do full screen. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 4cd48b1181..8df5a2000f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -525,13 +526,3 @@ class EmbeddedWebAppController( private const val MAX_CONSOLE_LOGS = 200 } } - -/** Maps the provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ -private fun consoleLevelOf(level: String): ConsoleLine.Level = - when (level) { - "ERROR" -> ConsoleLine.Level.ERROR - "WARNING" -> ConsoleLine.Level.WARNING - "DEBUG" -> ConsoleLine.Level.DEBUG - "TIP" -> ConsoleLine.Level.INFO - else -> ConsoleLine.Level.LOG - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt index 84e3bb70be..faa8cf7204 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/ConsoleBridge.kt @@ -33,3 +33,13 @@ interface ConsoleBridge { fun clearConsoleLogs() } + +/** Maps a provider's console level (WebView's `ConsoleMessage.MessageLevel` name) onto the chrome's. */ +fun consoleLevelOf(level: String): ConsoleLine.Level = + when (level) { + "ERROR" -> ConsoleLine.Level.ERROR + "WARNING" -> ConsoleLine.Level.WARNING + "DEBUG" -> ConsoleLine.Level.DEBUG + "TIP" -> ConsoleLine.Level.INFO + else -> ConsoleLine.Level.LOG + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt index 39d3fb0f6b..79a7ed1dd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/EmbeddedNostrAppController.kt @@ -35,19 +35,27 @@ import android.os.Message import android.os.Messenger import android.os.SystemClock import androidx.annotation.RequiresApi +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory import androidx.privacysandbox.ui.client.view.SandboxedSdkView import androidx.privacysandbox.ui.core.SandboxedUiAdapter +import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent import java.util.concurrent.atomic.AtomicLong @@ -67,7 +75,9 @@ class EmbeddedNostrAppController( private val params: Bundle, ) : EmbeddedSurfaceController, EmbeddedImeBridge, - EmbeddedMagnifierProbe { + EmbeddedMagnifierProbe, + ConsoleBridge, + FindBridge { private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage)) private var serviceMessenger: Messenger? = null private var bound = false @@ -112,6 +122,14 @@ class EmbeddedNostrAppController( override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null + /** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */ + override val consoleLogs = mutableStateListOf<ConsoleLine>() + + override fun clearConsoleLogs() = consoleLogs.clear() + + private val _findResult = mutableStateOf<FindResult?>(null) + override val findResult: State<FindResult?> = _findResult + private val connection = object : ServiceConnection { override fun onServiceConnected( @@ -266,6 +284,22 @@ class EmbeddedNostrAppController( } } } + NappletEmbedContract.MSG_FIND_RESULT -> { + val data = msg.data ?: return true + _findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL)) + } + NappletEmbedContract.MSG_CONSOLE_LOG -> { + val data = msg.data ?: return true + if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0) + consoleLogs.add( + ConsoleLine( + consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()), + data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(), + data.getString(NappletEmbedContract.KEY_CONSOLE_SOURCE).orEmpty(), + data.getInt(NappletEmbedContract.KEY_CONSOLE_LINE, 0), + ), + ) + } NappletEmbedContract.MSG_MAGNIFIER_FRAME -> { val data = msg.data ?: return true val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true @@ -305,6 +339,15 @@ class EmbeddedNostrAppController( fun reload() = send(NappletEmbedContract.MSG_RELOAD) + override fun find(query: String) { + if (query.isEmpty()) _findResult.value = null + send(NappletEmbedContract.MSG_FIND) { putString(NappletEmbedContract.KEY_FIND_QUERY, query) } + } + + override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) } + + fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) } + /** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */ override fun retry() { hasLoadedReal = false @@ -353,5 +396,7 @@ class EmbeddedNostrAppController( private companion object { private val SESSION_SEQ = AtomicLong() + + private const val MAX_CONSOLE_LOGS = 200 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index a3b12d3d61..3a7012cdcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -26,18 +26,19 @@ import androidx.activity.compose.BackHandler import androidx.annotation.RequiresApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.AlertDialog +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -50,11 +51,14 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillEvent import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPillUi import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp @@ -62,10 +66,6 @@ import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.favoriteIds import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.browser_unsupported -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_static -import com.vitorpamplona.amethyst.commons.resources.favorite_app_access_title -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_open -import com.vitorpamplona.amethyst.commons.resources.favorite_app_network_tor import com.vitorpamplona.amethyst.commons.resources.favorite_app_still_loading import com.vitorpamplona.amethyst.commons.resources.favorite_app_unavailable import com.vitorpamplona.amethyst.commons.resources.favorite_apps @@ -76,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.ui.loadStringRes import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry +import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -133,7 +134,10 @@ private fun EmbeddedNostrAppTab( // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). - val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } + // Bumped when the user re-routes an nSite (Tor ↔ open web): the session is rebuilt with the new route, + // as the full-screen host relaunches itself. + var networkEpoch by remember(coordinate) { mutableIntStateOf(0) } + val params = remember(coordinate, EmbeddedTabHost.rebuildEpoch, networkEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -145,16 +149,19 @@ private fun EmbeddedNostrAppTab( val capLabels = params.getStringArrayList(NappletHostContract.EXTRA_CAP_LABELS).orEmpty() val profile = HostProfile.fromName(params.getString(NappletHostContract.EXTRA_HOST_PROFILE)) val useTor = params.getBoolean(NappletHostContract.EXTRA_USE_TOR, true) + // Only nSites have a route of their own to choose, and only when Tor is running. + val torOn = if (profile.exposesNetwork && params.getInt(NappletHostContract.EXTRA_PROXY_PORT, -1) > 0) useTor else null val scope = rememberCoroutineScope() var canGoBack by remember { mutableStateOf(false) } var showAccess by remember { mutableStateOf(false) } + var textZoom by remember(coordinate) { mutableIntStateOf(BrowserChrome.DEFAULT_TEXT_ZOOM) } val apps by FavoriteAppsRegistry.favorites.collectAsStateWithLifecycle() val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id, EmbeddedTabHost.rebuildEpoch) { + remember(id, EmbeddedTabHost.rebuildEpoch, networkEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } @@ -174,30 +181,39 @@ private fun EmbeddedNostrAppTab( // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate, isFavorite, controller) { + remember(title, coordinate, isFavorite, torOn, textZoom, controller) { EmbeddedTabChrome( ui = BrowserPillUi( title = title.ifBlank { coordinate }, chrome = BrowserChrome.State( - surface = BrowserChrome.Surface.NAPPLET, + surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, presentation = BrowserChrome.Presentation.EMBEDDED, url = "", startUrl = "", + torOn = torOn, hasAccessInfo = true, - // The embedded nsite/napplet host has no find or text-size plumbing (yet). - hasFind = false, - hasTextSize = false, ), isFavorite = isFavorite, + textZoom = textZoom, ), onEvent = { event -> + if (event is BrowserPillEvent.TextZoom) { + textZoom = event.percent + controller.setTextZoom(event.percent) + } when ((event as? BrowserPillEvent.Action)?.action) { BrowserChrome.Action.RELOAD -> controller.reload() BrowserChrome.Action.OPEN_FULL_SCREEN -> FavoriteAppLauncher.launch(context, FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis()), appStillLoadingStr) BrowserChrome.Action.ACCESS_INFO -> showAccess = true + BrowserChrome.Action.TOR -> { + // Persist the new route, then rebuild the session so it loads that way. + NappletNetworkRegistry.set(permissionCoordinate, !useTor) + EmbeddedTabHost.evict(id) + networkEpoch++ + } BrowserChrome.Action.SITE_SETTINGS -> nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) BrowserChrome.Action.FAVORITE -> { val favId = "nostr:$coordinate" @@ -247,7 +263,22 @@ private fun EmbeddedNostrAppTab( BackHandler(enabled = canGoBack) { controller.back() } if (showAccess) { - AccessDialog(title, capLabels, profile.exposesNetwork, useTor) { showAccess = false } + Dialog(onDismissRequest = { showAccess = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = title.ifBlank { coordinate }, + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capLabels, + torOn = torOn, + onManagePermissions = { + showAccess = false + nav.nav(Route.ConnectedAppDetail(permissionCoordinate)) + }, + onDone = { showAccess = false }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } } Scaffold( @@ -295,36 +326,6 @@ private fun UnavailableTab( } } -@Composable -private fun AccessDialog( - title: String, - capLabels: List<String>, - showsNetwork: Boolean, - useTor: Boolean, - onDismiss: () -> Unit, -) { - val capsBody = - if (capLabels.isEmpty()) { - stringRes(Res.string.favorite_app_access_static) - } else { - capLabels.joinToString("\n") { "• $it" } - } - val networkBody = - if (showsNetwork) { - "\n\n" + stringRes(if (useTor) Res.string.favorite_app_network_tor else Res.string.favorite_app_network_open) - } else { - "" - } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text(if (title.isBlank()) stringRes(Res.string.favorite_app_access_title) else title) }, - text = { Text(capsBody + networkBody) }, - confirmButton = { - TextButton(onClick = onDismiss) { Text(stringRes(android.R.string.ok)) } - }, - ) -} - private fun noticeResId(notice: String): StringResource? = when (notice) { NappletEmbedContract.NOTICE_PUBLISHED -> Res.string.favorite_notice_published diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index d1e8fac7c4..49b19ed171 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5389,6 +5389,12 @@ <string name="browser_pill_site_settings_none">Nothing allowed yet</string> <string name="browser_pill_access">What it can access</string> <string name="browser_pill_access_desc">Your keys never leave Amethyst</string> + <string name="browser_pill_access_keys_desc">Every sign, publish, upload or payment goes through your approval</string> + <string name="browser_pill_access_none">No special access</string> + <string name="browser_pill_access_none_desc">It runs sandboxed and can't act on your account</string> + <string name="browser_pill_access_manage">Manage permissions</string> + <string name="browser_pill_access_nsite">nSite · sandboxed</string> + <string name="browser_pill_access_napplet">nApplet · sandboxed</string> <string name="browser_pill_console">Console</string> <string name="browser_pill_security_https">Secure connection</string> <string name="browser_pill_security_http">Not secure</string> diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt new file mode 100644 index 0000000000..e0bf7fdd64 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AccessInfoSheet.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser.ui.pill + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_keys_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_manage +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_napplet +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none_desc +import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_nsite +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open +import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor +import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off +import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on +import com.vitorpamplona.amethyst.commons.ui.stringRes + +/** + * "What it can access" for a sandboxed nSite or nApplet: the capabilities it was launched with, how it + * reaches the network (nSites only), and the promise that the keys stay in Amethyst. The sandboxed + * counterpart of [PageInfoSheet], drawn with the same header and grouped cards. + * + * [capabilities] are already-localized labels; empty means a static site with no special access. + * [torOn] is null when the app has no network route of its own to show. + */ +@Composable +fun AccessInfoSheet( + title: String, + isWebsite: Boolean, + capabilities: List<String>, + torOn: Boolean?, + onManagePermissions: (() -> Unit)?, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + Surface( + modifier = modifier.fillMaxWidth(), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + shadowElevation = 6.dp, + border = PillDefaults.hairline(), + ) { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .padding(PillDefaults.SheetPadding), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + SiteMonogram(title, size = 48.dp) + Spacer(Modifier.width(14.dp)) + Column { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Row(verticalAlignment = Alignment.CenterVertically) { + SecurityIcon(BrowserChrome.Security.SANDBOX, size = 14.dp) + Spacer(Modifier.width(4.dp)) + Text( + stringRes(if (isWebsite) Res.string.browser_pill_access_nsite else Res.string.browser_pill_access_napplet), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + GroupCard(heading = stringRes(Res.string.browser_pill_access)) { + if (capabilities.isEmpty()) { + GroupRow( + icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_none), + supporting = stringRes(Res.string.browser_pill_access_none_desc), + onClick = null, + ) + } else { + capabilities.forEachIndexed { index, label -> + if (index > 0) GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) }, + iconContainer = MaterialTheme.colorScheme.secondaryContainer, + title = label, + supporting = null, + onClick = null, + ) + } + } + GroupDivider() + GroupRow( + icon = { Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }, + iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(Res.string.browser_pill_access_desc), + supporting = stringRes(Res.string.browser_pill_access_keys_desc), + onClick = null, + ) + } + + torOn?.let { tor -> + GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) { + GroupRow( + icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) }, + iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest, + title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open), + supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off), + onClick = null, + ) + } + } + + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + if (onManagePermissions != null) { + TextButton(onClick = onManagePermissions) { Text(stringRes(Res.string.browser_pill_access_manage)) } + Spacer(Modifier.width(8.dp)) + } + Button(onClick = onDone) { Text(stringRes(Res.string.browser_pill_ok)) } + } + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt index ace3a1cf43..1a2531d2c4 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -238,3 +238,20 @@ fun PageInfoPreview() { } } } + +@Preview +@Composable +fun AccessInfoPreview() { + PreviewFrame { + Box(Modifier.padding(16.dp).width(380.dp)) { + AccessInfoSheet( + title = "Habla", + isWebsite = true, + capabilities = listOf("Sign events as you", "Publish to your relays", "Upload files"), + torOn = true, + onManagePermissions = {}, + onDone = {}, + ) + } + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt index 3ac0d504e9..08711fed43 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -89,6 +89,8 @@ class BrowserPillRenderTest { @Test fun pageInfo() = render("11-page-info", 820, 1250) { PageInfoPreview() } + @Test fun accessInfo() = render("12-access-info", 820, 610) { AccessInfoPreview() } + private companion object { const val SETTLE_FRAMES = 12 const val FRAME_MILLIS = 60L diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt index 5041339b99..a781eb4722 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -47,6 +47,7 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.browser.BrowserSitePermission +import com.vitorpamplona.amethyst.commons.browser.ui.pill.AccessInfoSheet import com.vitorpamplona.amethyst.commons.browser.ui.pill.AddressSuggestion import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserChromeTheme import com.vitorpamplona.amethyst.commons.browser.ui.pill.BrowserPill @@ -101,6 +102,15 @@ class BrowserChromeHost( fun onPanelsChanged() {} } + /** What a sandboxed app was launched with, for [showAccessInfo]. */ + class AccessInfo( + val title: String, + val isWebsite: Boolean, + val capabilities: List<String>, + val torOn: Boolean?, + val onManagePermissions: (() -> Unit)?, + ) + /** A page's JS dialog waiting for an answer. */ class PendingDialog( val type: PageDialogType, @@ -137,6 +147,7 @@ class BrowserChromeHost( var dialog by mutableStateOf<PendingDialog?>(null) var permissionPrompt by mutableStateOf<PendingPermission?>(null) private var pageInfoOpen by mutableStateOf(false) + private var accessInfo by mutableStateOf<AccessInfo?>(null) private var certificate by mutableStateOf<CertificateInfo?>(null) private var topView: ComposeView? = null @@ -203,6 +214,11 @@ class BrowserChromeHost( console.add(line) } + /** "What it can access" for a sandboxed nSite or nApplet. */ + fun showAccessInfo(info: AccessInfo) { + accessInfo = info + } + /** Page info for the page on screen, with its certificate when it has one. */ fun showPageInfo(certificate: CertificateInfo?) { this.certificate = certificate @@ -329,6 +345,27 @@ class BrowserChromeHost( ) } } + accessInfo?.let { info -> + Dialog(onDismissRequest = { accessInfo = null }, properties = DialogProperties(usePlatformDefaultWidth = false)) { + Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { + AccessInfoSheet( + title = info.title, + isWebsite = info.isWebsite, + capabilities = info.capabilities, + torOn = info.torOn, + onManagePermissions = + info.onManagePermissions?.let { manage -> + { + accessInfo = null + manage() + } + }, + onDone = { accessInfo = null }, + modifier = Modifier.widthIn(max = 560.dp), + ) + } + } + } if (pageInfoOpen) { Dialog(onDismissRequest = { pageInfoOpen = false }, properties = DialogProperties(usePlatformDefaultWidth = false)) { Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt index 1f977a02d3..38df1171f7 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletEmbedContract.kt @@ -115,6 +115,34 @@ object NappletEmbedContract { */ const val MSG_FILE_CHOOSER_RESULT = 19 + /** Client → provider: find [KEY_FIND_QUERY] in the page; an empty query clears the highlights. */ + const val MSG_FIND = 20 + + /** Client → provider: move to the next ([KEY_FIND_FORWARD] true) or previous match. */ + const val MSG_FIND_NEXT = 21 + + /** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */ + const val MSG_FIND_RESULT = 22 + + /** Client → provider: set the page's text size to [KEY_TEXT_ZOOM] percent. */ + const val MSG_SET_TEXT_ZOOM = 23 + + /** + * Provider → client: one line for the developer console — [KEY_CONSOLE_LEVEL] (WebView's + * `ConsoleMessage.MessageLevel` name), [KEY_CONSOLE_MESSAGE], [KEY_CONSOLE_SOURCE], [KEY_CONSOLE_LINE]. + */ + const val MSG_CONSOLE_LOG = 24 + + const val KEY_FIND_QUERY = "findQuery" + const val KEY_FIND_FORWARD = "findForward" + const val KEY_FIND_ACTIVE = "findActive" + const val KEY_FIND_TOTAL = "findTotal" + const val KEY_TEXT_ZOOM = "textZoom" + const val KEY_CONSOLE_LEVEL = "consoleLevel" + const val KEY_CONSOLE_MESSAGE = "consoleMessage" + const val KEY_CONSOLE_SOURCE = "consoleSource" + const val KEY_CONSOLE_LINE = "consoleLine" + const val KEY_FILE_CHOOSER_ID = "fileChooserId" const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept" const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple" diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index b16ab84463..e14cc25611 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -21,7 +21,6 @@ package com.vitorpamplona.amethyst.napplethost import android.annotation.SuppressLint -import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection @@ -213,9 +212,9 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (chrome?.handleBack() == true) { - Unit - } else if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { + // The chrome first (open pill, find), then the applet's own history. + if (chrome?.handleBack() == true) return + if (this@NappletHostActivity::webView.isInitialized && !webViewGone && webView.canGoBack()) { webView.goBack() } else { isEnabled = false @@ -1047,18 +1046,15 @@ class NappletHostActivity : ComponentActivity() { /** Lists, in plain language, exactly which capabilities this napplet was launched with. */ private fun showAccessDialog() { - val body = - if (capabilityLabels.isEmpty()) { - getString(R.string.napplet_chrome_static_site) - } else { - capabilityLabels.joinToString("\n") { "• $it" } + "\n\n" + getString(R.string.napplet_chrome_keys_safe) - } - AlertDialog - .Builder(this) - .setTitle(getString(R.string.napplet_chrome_access_title, barTitle())) - .setMessage(body) - .setPositiveButton(android.R.string.ok, null) - .show() + chrome?.showAccessInfo( + BrowserChromeHost.AccessInfo( + title = barTitle(), + isWebsite = profile == HostProfile.WEBSITE, + capabilities = capabilityLabels, + torOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, + onManagePermissions = if (brokerMessenger != null) ::openPermissions else null, + ), + ) } /** diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index b3a69b8d13..998117aad8 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -38,6 +38,7 @@ import android.os.Messenger import android.os.SystemClock import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage import android.webkit.JsPromptResult import android.webkit.JsResult import android.webkit.RenderProcessGoneDetail @@ -60,6 +61,7 @@ import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature +import com.vitorpamplona.amethyst.commons.browser.BrowserChrome import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract import com.vitorpamplona.amethyst.commons.util.booleanOrNull import com.vitorpamplona.amethyst.commons.util.parseJsonObjectOrNull @@ -129,6 +131,9 @@ class NappletHostService : Service() { // Last main-frame error state, pushed to the client so it can show an error/retry overlay over the // surface (the embedded surface has no error page of its own). var loadFailed = false + + // The user's text size, re-applied when a renderer crash forces a fresh WebView. + var textZoom = BrowserChrome.DEFAULT_TEXT_ZOOM val replyMessenger = Messenger(Handler(Looper.getMainLooper()) { onBrokerReply(this, it) }) } @@ -213,6 +218,17 @@ class NappletHostService : Service() { tab.bridgeReplyProxy?.postMessage(payload) } NappletEmbedContract.MSG_MAGNIFIER_REQUEST -> onMagnifierRequest(msg) + NappletEmbedContract.MSG_FIND -> { + val wv = tabFor(msg)?.webView ?: return true + val query = msg.data?.getString(NappletEmbedContract.KEY_FIND_QUERY).orEmpty() + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } + NappletEmbedContract.MSG_FIND_NEXT -> tabFor(msg)?.webView?.findNext(msg.data?.getBoolean(NappletEmbedContract.KEY_FIND_FORWARD, true) ?: true) + NappletEmbedContract.MSG_SET_TEXT_ZOOM -> { + val tab = tabFor(msg) ?: return true + tab.textZoom = msg.data?.getInt(NappletEmbedContract.KEY_TEXT_ZOOM, tab.textZoom) ?: tab.textZoom + tab.webView?.let { BrowserWebTools.setTextZoom(it, tab.textZoom) } + } NappletEmbedContract.MSG_FILE_CHOOSER_RESULT -> { val tab = tabFor(msg) ?: return true val data = msg.data ?: return true @@ -362,6 +378,8 @@ class NappletHostService : Service() { wv.dropSystemBarInsets() if (tab.profile.exposesNetwork) applyWebViewProxy(effectiveProxy) WebViewCompat.addWebMessageListener(wv, NappletWebContract.BRIDGE_NAME, setOf(NappletWebContract.ORIGIN), ::onShellMessage) + wv.setFindListener { active, total, _ -> pushFindResult(tab, active, total) } + if (tab.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM) BrowserWebTools.setTextZoom(wv, tab.textZoom) tab.webView = wv wv.loadUrl(NappletWebContract.SHELL_URL) return wv @@ -426,6 +444,11 @@ class NappletHostService : Service() { fileChooserParams: FileChooserParams, ): Boolean = requestFileChooser(tab, filePathCallback, fileChooserParams) + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + pushConsoleLog(tab, consoleMessage.messageLevel().name, consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + return true + } + // Setting a chrome client at all is what opts this WebView into the default JS-dialog handling, // and this one is built from a Service context — there is no window token to attach a dialog to, // and an applet's alert() must not be able to draw over the main app's trusted chrome anyway. @@ -547,6 +570,7 @@ class NappletHostService : Service() { request: WebResourceRequest, error: WebResourceError, ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty()), request.url?.toString().orEmpty(), 0) // Only a main-frame failure blanks the applet; a missing sub-resource is irrelevant to whether // it opened. if (!request.isForMainFrame) return @@ -554,6 +578,14 @@ class NappletHostService : Service() { pushLoadState(tab, isLoading = false) } + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + pushConsoleLog(tab, ConsoleMessage.MessageLevel.ERROR.name, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty()), request.url?.toString().orEmpty(), 0) + } + /** * The renderer died. It is shared by every WebView in `:napplet`, and an unhandled crash kills the * whole process — every other tab included. Drop just this tab's WebView and report the load as @@ -599,6 +631,42 @@ class NappletHostService : Service() { runCatching { tab.clientMessenger?.send(message) } } + private fun pushFindResult( + tab: NappletTab, + active: Int, + total: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_FIND_RESULT).apply { + data = + Bundle().apply { + putInt(NappletEmbedContract.KEY_FIND_ACTIVE, active) + putInt(NappletEmbedContract.KEY_FIND_TOTAL, total) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + + private fun pushConsoleLog( + tab: NappletTab, + level: String, + text: String, + source: String, + line: Int, + ) { + val message = + Message.obtain(null, NappletEmbedContract.MSG_CONSOLE_LOG).apply { + data = + Bundle().apply { + putString(NappletEmbedContract.KEY_CONSOLE_LEVEL, level) + putString(NappletEmbedContract.KEY_CONSOLE_MESSAGE, text) + putString(NappletEmbedContract.KEY_CONSOLE_SOURCE, source) + putInt(NappletEmbedContract.KEY_CONSOLE_LINE, line) + } + } + runCatching { tab.clientMessenger?.send(message) } + } + /** Tells the client whether a main-frame load is in flight and whether it failed, so it can overlay a spinner/retry. */ private fun pushLoadState( tab: NappletTab, diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 2ac71b58ff..d55e67ec81 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -4,30 +4,11 @@ <string name="napplet_invalid">Invalid nApplet.</string> <string name="napplet_webview_too_old">This device\'s WebView is too old to run nApplets safely.</string> - - <!-- Trusted chrome (top bar + live action notices) --> - <string name="napplet_chrome_access_title">What “%1$s” can access</string> - <string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string> - <string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string> - <string name="napplet_chrome_permissions_desc">What this app can access</string> - <string name="napplet_chrome_manage_permissions">Manage permissions</string> - <string name="napplet_chrome_reload">Reload</string> - <!-- Browser address bar and developer console strings are in :commons --> + <!-- Live "allow always" action notices --> <string name="napplet_action_published">“%1$s” published a note as you</string> <string name="napplet_action_uploaded">“%1$s” uploaded a file</string> <string name="napplet_action_paid">“%1$s” made a payment</string> - <!-- nSite network routing (Tor vs open web) --> - <string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string> - <string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string> - <!-- Short labels for the pull-down sheet's network row --> - <string name="napplet_net_tor_label">Loads over Tor</string> - <string name="napplet_net_open_label">Loads over the open web</string> - - <!-- Favorite toggle in the pull-down sheet --> - <string name="browser_favorite_add">Add to favorites</string> - <string name="browser_favorite_remove">Remove from favorites</string> - <!-- Loading / unavailable screens --> <string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string> <string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string> From 0dca3cb11b125af60b777a36d2bab00b060b8a47 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona <vitor@vitorpamplona.com> Date: Sat, 26 Sep 2026 20:42:32 -0400 Subject: [PATCH 14/14] fix(browser): the two things that looked wrong in the pill and the Recent list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **The tile grid stranded its remainder.** Six actions filled a row of four and left two beside a hole. The rows now share the tiles out evenly — six is 3 + 3, five is 3 + 2 — and a gap only appears when the count will not divide, which for seven is 4 + 3 either way. Rows are still padded back out to four where they draw, so every tile keeps the same width. `balancedRowSizes` returns the row sizes rather than one column count: the first shape of this returned a single number and `chunked` it, which quietly gave 4 + 4 + 2 for ten instead of 4 + 3 + 3. The test caught that, so the function changed rather than the expectation. **Two rows in Recent both read "Primal / primal.net".** They are not duplicates — they are `primal.net/home` and `primal.net`, two pages that happen to share a `<title>`. The second line was only ever the host, so there was no way to tell them apart or to know which one a tap would open. It now shows host and path, minus the scheme and a bare trailing slash: `primal.net/home` against `primal.net`. While in there, `BrowserHistoryRegistry` now keys on a normalised URL instead of the raw string. That is not what caused the pair above, but a trailing slash, a different case in the host or a leftover `#fragment` would each have produced a genuine duplicate, and `distinctBy { it.url }` could not see it. Verified on an SM-T220: the Recent rows read `primal.net/home` and `primal.net`, and the six-tile pill renders as two rows of three. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../favorites/BrowserHistoryRegistry.kt | 36 ++++++++- .../screen/loggedIn/browser/BrowserScreen.kt | 23 +++++- .../favorites/BrowserHistoryKeyTest.kt | 69 ++++++++++++++++ .../amethyst/favorites/RecentSubtitleTest.kt | 62 ++++++++++++++ .../commons/browser/ui/pill/BrowserPill.kt | 39 +++++++-- .../browser/ui/pill/BalancedRowSizesTest.kt | 80 +++++++++++++++++++ 6 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryKeyTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/RecentSubtitleTest.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BalancedRowSizesTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt index 5564139886..57494e95f9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryRegistry.kt @@ -101,8 +101,9 @@ object BrowserHistoryRegistry { ) { val host = OmniboxInput.hostOf(url) ?: url val now = System.currentTimeMillis() + val key = historyKey(url) update { current -> - val existing = current.firstOrNull { it.url == url } + val existing = current.firstOrNull { historyKey(it.url) == key } val entry = if (existing != null) { existing.copy( @@ -114,18 +115,22 @@ object BrowserHistoryRegistry { } else { BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1) } - (listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES) + (listOf(entry) + current.filterNot { historyKey(it.url) == key }).take(MAX_ENTRIES) } } - fun remove(url: String) = update { current -> current.filterNot { it.url == url } } + fun remove(url: String) = + update { current -> + val key = historyKey(url) + current.filterNot { historyKey(it.url) == key } + } fun clear() = update { emptyList() } private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> = list .sortedByDescending { it.lastVisitedAt } - .distinctBy { it.url } + .distinctBy { historyKey(it.url) } .take(MAX_ENTRIES) private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) { @@ -152,3 +157,26 @@ object BrowserHistoryRegistry { emptyList() } } + +/** + * What counts as "the same page" in the history list. + * + * Keying on the raw URL string put `primal.net` in the list twice: a trailing slash, a different case + * in the host, or a leftover `#fragment` reads as one page and compares as two Strings. So the scheme + * and host are lowercased, a fragment is dropped, and a trailing slash is dropped when the path is + * nothing but that slash. + * + * The path and the query are kept and compared as-is. Two pages of the same site are two entries, and + * guessing which query parameters are load-bearing is how you lose one of them. + */ +internal fun historyKey(url: String): String { + val noFragment = url.substringBefore('#') + val schemeEnd = noFragment.indexOf("://") + if (schemeEnd <= 0) return noFragment + val scheme = noFragment.substring(0, schemeEnd).lowercase() + val rest = noFragment.substring(schemeEnd + 3) + val authorityEnd = rest.indexOfFirst { it == '/' || it == '?' } + val authority = (if (authorityEnd < 0) rest else rest.substring(0, authorityEnd)).lowercase() + val tail = if (authorityEnd < 0) "" else rest.substring(authorityEnd) + return scheme + "://" + authority + if (tail == "/") "" else tail +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 06f038fb22..f65ff428d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -745,7 +745,11 @@ private fun RecentRow( overflow = TextOverflow.Ellipsis, ) Text( - entry.host, + // Host and path, not just the host: two pages of one site usually carry the + // same <title>, so `primal.net/home` and `primal.net` arrived as two rows + // reading "Primal / primal.net" and there was no way to tell them apart or + // to know which one a tap would open. + remember(entry.url, entry.host) { recentSubtitle(entry.url, entry.host) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -818,3 +822,20 @@ private fun SiteIcon( /** The host of [url] for a favorite's default label, falling back to the raw string. */ private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url + +/** + * The second line of a Recent row: what a reader needs to tell two rows of one site apart. + * + * The scheme and a bare trailing slash are noise at this size, so they go; everything after the + * host stays, because that is the part that differs. Falls back to the host when the URL has + * nothing more to say. + */ +internal fun recentSubtitle( + url: String, + host: String, +): String { + val schemeEnd = url.indexOf("://") + val afterScheme = if (schemeEnd > 0) url.substring(schemeEnd + 3) else url + val trimmed = afterScheme.removeSuffix("/") + return trimmed.ifBlank { host } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryKeyTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryKeyTest.kt new file mode 100644 index 0000000000..842fe99427 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/BrowserHistoryKeyTest.kt @@ -0,0 +1,69 @@ +/* + * 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.favorites + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** The list showed `primal.net` twice; these are the pairs that produced that. */ +class BrowserHistoryKeyTest { + @Test + fun `a trailing slash on the root is the same page`() { + assertEquals(historyKey("https://primal.net"), historyKey("https://primal.net/")) + } + + @Test + fun `the host is compared without case`() { + assertEquals(historyKey("https://Primal.NET/"), historyKey("https://primal.net/")) + } + + @Test + fun `a fragment is not a different page`() { + assertEquals(historyKey("https://primal.net/home"), historyKey("https://primal.net/home#top")) + } + + @Test + fun `a path is still its own page`() { + assertNotEquals(historyKey("https://primal.net/"), historyKey("https://primal.net/home")) + } + + @Test + fun `a query is still its own page`() { + assertNotEquals(historyKey("https://x.com/search?q=a"), historyKey("https://x.com/search?q=b")) + } + + @Test + fun `a trailing slash deeper in the path is left alone`() { + // Servers are free to treat these as different, so we do not decide for them. + assertNotEquals(historyKey("https://primal.net/home"), historyKey("https://primal.net/home/")) + } + + @Test + fun `http and https are different origins`() { + assertNotEquals(historyKey("http://primal.net/"), historyKey("https://primal.net/")) + } + + @Test + fun `something that is not a url is returned as itself`() { + assertEquals("not a url", historyKey("not a url")) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/RecentSubtitleTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/RecentSubtitleTest.kt new file mode 100644 index 0000000000..988495bced --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/favorites/RecentSubtitleTest.kt @@ -0,0 +1,62 @@ +/* + * 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.favorites + +import com.vitorpamplona.amethyst.ui.screen.loggedIn.browser.recentSubtitle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +/** The two rows that both read "Primal / primal.net" and could not be told apart. */ +class RecentSubtitleTest { + @Test + fun `two pages of one site no longer read the same`() { + assertNotEquals( + recentSubtitle("https://primal.net/", "primal.net"), + recentSubtitle("https://primal.net/home", "primal.net"), + ) + } + + @Test + fun `the root shows the bare host`() { + assertEquals("primal.net", recentSubtitle("https://primal.net/", "primal.net")) + } + + @Test + fun `a path is shown after the host`() { + assertEquals("primal.net/home", recentSubtitle("https://primal.net/home", "primal.net")) + } + + @Test + fun `a port is kept, since it is part of where you are going`() { + assertEquals("localhost:8000/t.html", recentSubtitle("http://localhost:8000/t.html", "localhost")) + } + + @Test + fun `a query is kept`() { + assertEquals("x.com/search?q=nostr", recentSubtitle("https://x.com/search?q=nostr", "x.com")) + } + + @Test + fun `a url with nothing to add falls back to the host`() { + assertEquals("primal.net", recentSubtitle("", "primal.net")) + } +} 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 ca09cc1bc8..b0b5d88e14 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 @@ -353,7 +353,7 @@ private fun OutOfScopeBanner( } } -/** Page actions as tiles, four per row; toggles (desktop site, text size) fill while on. */ +/** Page actions as tiles, at most four per row; toggles (desktop site, text size) fill while on. */ @Composable private fun TileGrid( actions: List<Action>, @@ -361,9 +361,16 @@ private fun TileGrid( textSizeOpen: Boolean, onAction: (Action) -> Unit, ) { - val columns = 4 + // Spread over the rows we are going to use anyway, rather than filling each + // one to four and leaving the remainder stranded. Six actions read as 3 + 3 + // instead of 4 + 2 with a hole beside it, and five as 3 + 2 instead of 4 + 1. + // Every row is chunked to the same width, so the tiles stay a uniform size + // and a gap appears only when the count is not divisible — seven is 4 + 3 + // either way. + var taken = 0 + val rows = balancedRowSizes(actions.size, MAX_TILE_COLUMNS).map { size -> actions.subList(taken, taken + size).also { taken += size } } Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - actions.chunked(columns).forEach { rowActions -> + rows.forEach { rowActions -> Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { rowActions.forEach { action -> // The hand-off tile names the browser it will actually open when the @@ -384,13 +391,35 @@ private fun TileGrid( modifier = Modifier.weight(1f), ) } - // Keep the last row's tiles the same width as the others. - repeat(columns - rowActions.size) { Spacer(Modifier.weight(1f)) } + // Keep every row's tiles the same width, whatever the row holds. + repeat(MAX_TILE_COLUMNS - rowActions.size) { Spacer(Modifier.weight(1f)) } } } } } +/** Most tiles on one row. Four is what fits at the pill's width without the labels wrapping twice. */ +private const val MAX_TILE_COLUMNS = 4 + +/** + * How many tiles go on each row, spread as evenly as the count allows. + * + * The number of rows is fixed by [max] either way — this only decides how the + * items are shared out between them, so the leftovers do not all land on the + * last row. Ten tiles are 4 + 3 + 3, not 4 + 4 + 2. Rows are padded back out + * to [max] where they draw, so the tiles stay a uniform width throughout. + */ +internal fun balancedRowSizes( + count: Int, + max: Int, +): List<Int> { + if (count <= 0) return emptyList() + val rows = (count + max - 1) / max + val base = count / rows + val remainder = count % rows + return List(rows) { index -> base + if (index < remainder) 1 else 0 } +} + /** Text size: a stepped slider between a small and a large "A", the value, and a way back to 100%. */ @Composable fun TextSizeControl( diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BalancedRowSizesTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BalancedRowSizesTest.kt new file mode 100644 index 0000000000..787661cdf2 --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BalancedRowSizesTest.kt @@ -0,0 +1,80 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals + +class BalancedRowSizesTest { + private fun rows( + count: Int, + max: Int = 4, + ) = balancedRowSizes(count, max) + + @Test + fun `six fills two rows evenly instead of stranding two`() { + assertEquals(listOf(3, 3), rows(6)) + } + + @Test + fun `five is three and two, not four and one`() { + assertEquals(listOf(3, 2), rows(5)) + } + + @Test + fun `seven cannot be even and stays four and three`() { + assertEquals(listOf(4, 3), rows(7)) + } + + @Test + fun `a full row is left alone`() { + assertEquals(listOf(4), rows(4)) + assertEquals(listOf(4, 4), rows(8)) + } + + @Test + fun `no row is ever wider than the maximum, and every tile is placed`() { + (1..24).forEach { count -> + val rows = rows(count) + assertEquals(true, rows.all { it in 1..4 }, "count=$count rows=$rows") + assertEquals(count, rows.sum(), "count=$count rows=$rows") + } + } + + @Test + fun `rows never differ by more than one`() { + (1..24).forEach { count -> + val rows = rows(count) + assertEquals(true, rows.max() - rows.min() <= 1, "count=$count rows=$rows") + } + } + + @Test + fun `it never puts everything on one row it cannot fit`() { + assertEquals(listOf(4, 4, 4), rows(12)) + assertEquals(listOf(4, 3, 3), rows(10)) + } + + @Test + fun `an empty grid does not divide by zero`() { + assertEquals(emptyList(), balancedRowSizes(0, 4)) + } +}