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..8c01bd3c6d --- /dev/null +++ b/amethyst/plans/2026-09-26-browser-pwa-parity.md @@ -0,0 +1,361 @@ +# Browser surfaces → PWA parity review + +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. + +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)`. 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. | +| 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 │ +│ ✎ 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 │ +│ 🔍 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 +└──────────────────────────────────────────────┘ +``` + +- **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 + 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" (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` — 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 + `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 + 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** (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). 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`. +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; 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. Decisions (2026-09-26) + +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. + +## 7. Implementation (2026-09-26) + +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 +`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. + 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..2797131d3d --- /dev/null +++ b/amethyst/plans/2026-09-26-browser-ui-review.md @@ -0,0 +1,261 @@ +# Browser surfaces: UI review and redesign + +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/`. + +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. What shipped + +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. + +**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/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index c4304d3485..d2b9dabc65 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -17,6 +17,17 @@ + + + + + + + + + + + + apps.filterIsInstance().map { it.url to it.label } } + .distinctUntilChanged() + .collect { WebShortcuts.publishFavorites(this@Amethyst, instance.favoriteApps.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). instance.browserHistory.init() 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..991c66dc63 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/WebShortcuts.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.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.Amethyst +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, + ) { + val shortcuts = + favorites + .filterIsInstance() + .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 = + Amethyst.instance.browserIcons + .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 3340367115..30007d4ec8 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 @@ -42,6 +43,7 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityWatch import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse +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 @@ -217,7 +219,7 @@ 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 @@ -225,11 +227,69 @@ class NappletBrokerService : Service() { val favorites = Amethyst.instance.favoriteApps favorites.init() val id = "url:$url" - if (favorites.isFavorite(id)) { - favorites.remove(id) - } else { + // The star sends the state it wants (it flips what it shows); an older client sends none: toggle. + val target = + if (data.containsKey(NappletIpc.KEY_FAVORITE_IS_FAVORITE)) { + data.getBoolean(NappletIpc.KEY_FAVORITE_IS_FAVORITE) + } else { + !favorites.isFavorite(id) + } + if (target) { favorites.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis())) + } else { + favorites.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 + Amethyst.instance.favoriteApps.init() + replyWebFavoriteState(replyTo, url) + 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 + Amethyst.instance.browserIcons.init() + WebShortcuts.requestPin(applicationContext, url, data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty()) return true } @@ -460,6 +520,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, Amethyst.instance.favoriteApps.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/napplet/WebSitePermissionRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt new file mode 100644 index 0000000000..efd41df623 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/WebSitePermissionRegistry.kt @@ -0,0 +1,115 @@ +/* + * 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.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.Amethyst +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 + +/** + * The per-site permission file, on the app-wide holder rather than a `Context` delegate (the same + * `filesDir/datastore/web_site_permissions.preferences_pb` path the delegate used). + * + * Main process only: [Amethyst.instance] is deliberately unset in the `:napplet` sandbox, which reaches + * these answers through the broker. + */ +private val webSitePermissionDataStore: DataStore + get() = Amethyst.instance.appStores.getDataStore("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 `"|"` keys. + */ +object WebSitePermissionRegistry { + private val _decisions = MutableStateFlow>>(emptyMap()) + + /** origin → permission → decision (only answered permissions appear). */ + val decisions: StateFlow>> = _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>() + 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) + } + if (appContext == null) return + scope.launch { + 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/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index cc5430711b..cfac4dccb1 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 , 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/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..65eedabf6d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedPageRequests.kt @@ -0,0 +1,42 @@ +/* + * 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 +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: 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, +) + +/** 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 3858281234..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 @@ -35,21 +35,29 @@ 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.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 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 @@ -69,7 +77,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 @@ -96,7 +105,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() @@ -106,8 +115,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: (url, canGoBack). */ - var onUrlChanged: ((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) + + /** 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) + + /** The page is showing HTML fullscreen (a video) inside the surface. */ + val isFullscreen = mutableStateOf(false) override var onImeEvent: ((ImeEvent) -> Unit)? = null @@ -150,6 +179,9 @@ class EmbeddedWebAppController( onMagnifierFrame = null onLoadStatusChanged = null consoleLogs.clear() + pendingDialog.value = null + pendingPermission.value = null + isFullscreen.value = false } override fun teardown() = unbind() @@ -235,7 +267,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 - onUrlChanged?.invoke(url, canGoBack) + 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, canGoForward) } NappletBrowserContract.MSG_IME_EVENT -> { val payload = msg.data?.getString(NappletBrowserContract.KEY_IME_PAYLOAD) ?: return true @@ -253,7 +287,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 @@ -274,6 +308,66 @@ 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 -> { + 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) + // 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" -> 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(), + 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 @@ -329,6 +423,7 @@ class EmbeddedWebAppController( private fun publishLoadStatus(status: EmbeddedLoadStatus) { loadStatus = status + isLoading.value = status.isLoading onLoadStatusChanged?.invoke(status) } @@ -336,6 +431,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() { + pageCertificate.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 424c15c1bc..946c2c9a40 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,19 +20,28 @@ */ 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 +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 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 +51,24 @@ 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 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.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 @@ -54,12 +77,16 @@ 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.FavoriteAppLauncher +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, @@ -102,7 +129,13 @@ 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) } + 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 @@ -121,44 +154,133 @@ 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, back -> - if (newUrl != "about:blank") currentUrl = newUrl + controller.onUrlChanged = { newUrl, title, back, forward -> + if (newUrl != "about:blank") { + currentUrl = newUrl + pageTitle = title + } canGoBack = back + canGoForward = forward } } + fun toggleFavorite() { + val favId = "url:$currentUrl" + val favorites = Amethyst.instance.favoriteApps + if (favorites.isFavorite(favId)) { + favorites.remove(favId) + } else { + favorites.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")) } + } + + 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 Amethyst.instance.browserHistory.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, torOn, proxyAvailable, isFavorite, controller) { + remember(currentUrl, pageTitle, canGoBack, canGoForward, isLoading, torOn, proxyAvailable, isFavorite, desktopSite, textZoom, sitePermissions, candidates, controller) { EmbeddedTabChrome( - title = hostLabel(currentUrl), - isSandbox = false, - onReload = { controller.reload() }, - onOpenFull = { FavoriteAppLauncher.launchUrl(context, url) }, - torOn = if (proxyAvailable) torOn else null, - onToggleTor = { - torOn = !torOn - controller.setTor(torOn) - WebAppNetworkRegistry.set(url, torOn) - }, - isFavorite = isFavorite, - onFavorite = { - val favId = "url:$currentUrl" - if (Amethyst.instance.favoriteApps.isFavorite(favId)) { - Amethyst.instance.favoriteApps.remove(favId) - } else { - Amethyst.instance.favoriteApps.add(FavoriteApp.WebApp(currentUrl, hostLabel(currentUrl), System.currentTimeMillis())) + 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, + ), + 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) + } + BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(context, currentUrl) + BrowserPillEvent.PageInfo -> { + controller.requestPageInfo() + showPageInfo = true + } + BrowserPillEvent.Close -> 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")) } - }, + suggestionsFor = { typed -> + OmniboxSuggestions.rank(typed, candidates, limit = 5).map { AddressSuggestion(it.label, it.url, it.isFavorite) } + }, ) } // Publish the top-sheet controls to the tab layer (which draws them over the z-below surface). In a @@ -176,7 +298,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, chrome.ui, showPageInfo, onPageInfoDismiss = { showPageInfo = false }) Scaffold( bottomBar = { @@ -194,6 +321,120 @@ 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 — the shared + * [PageDialogCard], [PermissionPromptCard] and [PageInfoSheet]. + */ +@RequiresApi(Build.VERSION_CODES.R) +@Composable +private fun EmbeddedPageUi( + controller: EmbeddedWebAppController, + ui: BrowserPillUi, + showPageInfo: Boolean, + onPageInfoDismiss: () -> Unit, +) { + val context = LocalContext.current + + val dialog by controller.pendingDialog + dialog?.let { d -> + 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. + 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 { + // 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) 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 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), + ) + } + } + } +} + +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/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..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 @@ -21,21 +21,25 @@ 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, -) +/** 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/embed/EmbeddedTabChrome.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabChrome.kt index fc28e0cf7d..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,30 +20,24 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed +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 sheet — 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. + * 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 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, - /** 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 isFavorite: Boolean = false, - /** Toggles the current site/app in the favorites registry; null when not applicable. */ - val onFavorite: (() -> Unit)? = null, + 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 5865febe5b..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 @@ -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 @@ -82,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 @@ -125,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). * @@ -267,73 +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) { + 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)) { { - if (consoleShowing) { - consoleShowing = false - } else { - consoleShowing = true - consoleExpanded = true - } + 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), ) - } - // 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 }, + // 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), + ) + } + + // 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), ) } } @@ -966,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/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 deleted file mode 100644 index 8565ce9203..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/TopControlSheet.kt +++ /dev/null @@ -1,259 +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.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.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -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.text.style.TextOverflow -import androidx.compose.ui.unit.dp -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.ui.stringRes -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). - * - * @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. - */ -@Composable -fun TopControlSheet( - chrome: EmbeddedTabChrome, - expanded: Boolean, - onExpandedChange: (Boolean) -> Unit, - modifier: Modifier = Modifier, - consoleCount: Int = 0, - consoleShowing: Boolean = false, - onConsole: (() -> Unit)? = null, -) { - 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)) { - 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, - ) - } - 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() - } - } - 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() - }, - ) - } - } - } - } - - // 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 SheetItem( - symbol: MaterialSymbol, - label: String, - onClick: () -> Unit, -) { - Row( - Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 8.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(14.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge) - } -} - -@Composable -private fun SheetSwitchItem( - symbol: MaterialSymbol, - 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, - ) { - Icon(symbol, contentDescription = null, modifier = Modifier.size(22.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) - Spacer(Modifier.width(14.dp)) - Text(label, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f)) - Switch(checked = checked, onCheckedChange = { onToggle() }) - } -} 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 552dfc6441..953adc5305 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,20 +51,22 @@ 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.Amethyst +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 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 @@ -73,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.favorite_notice_uploaded 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.napplet.NappletNetworkRegistry import com.vitorpamplona.amethyst.napplethost.HostProfile import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract import com.vitorpamplona.amethyst.napplethost.NappletHostContract @@ -130,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 @@ -142,17 +149,20 @@ 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 Amethyst.instance.favoriteApps.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) } @@ -172,21 +182,50 @@ 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( - 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)) }, - isFavorite = isFavorite, - onFavorite = { - val favId = "nostr:$coordinate" - if (Amethyst.instance.favoriteApps.isFavorite(favId)) { - Amethyst.instance.favoriteApps.remove(favId) - } else { - Amethyst.instance.favoriteApps.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + ui = + BrowserPillUi( + title = title.ifBlank { coordinate }, + chrome = + BrowserChrome.State( + surface = if (profile == HostProfile.WEBSITE) BrowserChrome.Surface.NSITE else BrowserChrome.Surface.NAPPLET, + presentation = BrowserChrome.Presentation.EMBEDDED, + url = "", + startUrl = "", + torOn = torOn, + hasAccessInfo = true, + ), + 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" + val favorites = Amethyst.instance.favoriteApps + if (favorites.isFavorite(favId)) { + favorites.remove(favId) + } else { + favorites.add(FavoriteApp.NostrApp(coordinate, title, System.currentTimeMillis())) + } + } + else -> Unit } }, ) @@ -226,7 +265,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( @@ -274,36 +328,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/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 9e7491a45d..5386f09d5a 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 @@ -113,6 +116,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.topbars.TopBarWithBackBu 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 @@ -320,6 +324,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)) @@ -340,6 +349,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 @@ -533,6 +547,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( 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/commons/src/androidMain/res/values/strings.xml b/commons/src/androidMain/res/values/strings.xml index 35d0cd1366..2d9ffabb12 100644 --- a/commons/src/androidMain/res/values/strings.xml +++ b/commons/src/androidMain/res/values/strings.xml @@ -9,4 +9,39 @@ <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 actions --> + <string name="browser_link_copied">Link copied</string> + <string name="browser_no_other_browser">No other browser installed</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> + + <string name="browser_site_data_cleared">Site data cleared</string> + + <!-- Site permissions (camera / microphone / location) --> + <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_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/BrowserHistoryRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt index 3d4f010948..87530bfe3b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryRegistry.kt @@ -99,8 +99,9 @@ class BrowserHistoryRegistry( ) { val host = OmniboxInput.hostOf(url) ?: url val now = TimeUtils.nowMillis() + 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( @@ -112,18 +113,22 @@ class 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>) { @@ -158,3 +163,26 @@ class BrowserHistoryRegistry( private const val MAX_ENTRIES = 500 } } + +/** + * 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/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/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryKeyTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/BrowserHistoryKeyTest.kt new file mode 100644 index 0000000000..6fd71f58eb --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/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.commons.browser + +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/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf index c9e24fb3ff..05533f6459 100644 Binary files a/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf and b/commonsUI/src/commonMain/composeResources/font/material_symbols_outlined.ttf differ diff --git a/commonsUI/src/commonMain/composeResources/values/strings.xml b/commonsUI/src/commonMain/composeResources/values/strings.xml index a49c20d30c..2243b4691f 100644 --- a/commonsUI/src/commonMain/composeResources/values/strings.xml +++ b/commonsUI/src/commonMain/composeResources/values/strings.xml @@ -5467,6 +5467,113 @@ <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_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> + <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_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> + <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> <string name="backup_action_block_again">Block %1$s again</string> <string name="backup_action_keep_count">Keep %1$s</string> <string name="backup_action_rejoin">Rejoin %1$s</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/AddressEditor.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt new file mode 100644 index 0000000000..3175737aec --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/AddressEditor.kt @@ -0,0 +1,227 @@ +/* + * 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 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, + 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) + } + + 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)) + } + } + } + + // [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 = { 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), + ) + } + + 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/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 new file mode 100644 index 0000000000..b0b5d88e14 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPill.kt @@ -0,0 +1,548 @@ +/* + * 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_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 +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, + suggestionsFor: (String) -> List<AddressSuggestion> = { emptyList() }, + clipboardUrl: String? = null, + initiallyEditing: Boolean = false, + initiallyTextSizeOpen: Boolean = false, + onPasteAndGo: (() -> Unit)? = null, +) { + 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, + suggestionsFor = suggestionsFor, + clipboardUrl = clipboardUrl, + initiallyEditing = initiallyEditing, + initiallyTextSizeOpen = initiallyTextSizeOpen, + onPasteAndGo = onPasteAndGo, + ) + } + PillHandle(ui, expanded, onExpandedChange) + } +} + +/** + * 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( + ui: BrowserPillUi, + expanded: Boolean, + onExpandedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + val barColor = + when (ui.security) { + BrowserChrome.Security.HTTP -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f) + } + 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(5.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.error.copy(alpha = 0.8f)), + ) + } + } +} + +/** 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, + 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) } + 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, + suggestionsFor = suggestionsFor, + clipboardUrl = clipboardUrl, + onGo = { onEvent(BrowserPillEvent.Navigate(it)) }, + onCancel = { editing = false }, + onPasteAndGo = onPasteAndGo, + ) + } 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, + onSecurityTap = if (!ui.chrome.isSandbox) ({ onEvent(BrowserPillEvent.PageInfo) }) 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, at most 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, +) { + // 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)) { + 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 + // 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 = 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) { + Action.DESKTOP_SITE -> ui.desktopSite + Action.TEXT_SIZE -> textSizeOpen || ui.textZoom != BrowserChrome.DEFAULT_TEXT_ZOOM + else -> null + }, + modifier = 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( + 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..e4ef87c8ee --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillModel.kt @@ -0,0 +1,100 @@ +/* + * 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(), + /** + * 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) +} + +/** 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 + + /** The security badge at the start of the origin field was tapped: show page info. */ + data object PageInfo : 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..1a2531d2c4 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillPreviews.kt @@ -0,0 +1,257 @@ +/* + * 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 = {}, + suggestionsFor = { 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) + } + } +} + +@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/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..c2f25cb6cc --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/ConsoleSheet.kt @@ -0,0 +1,238 @@ +/* + * 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_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 +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, + onClose: (() -> Unit)? = null, +) { + 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)) + } + 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), + 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..226af88eb5 --- /dev/null +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/PillComponents.kt @@ -0,0 +1,355 @@ +/* + * 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, + onSecurityTap: (() -> Unit)? = null, +) { + 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 = if (onSecurityTap != null) 4.dp else 16.dp, end = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // 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, + 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 d9bb7ffb45..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 @@ -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") @@ -76,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") @@ -88,6 +91,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 +130,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") @@ -170,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/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)) + } +} 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..cbabd7772c --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/browser/ui/pill/BrowserPillRenderTest.kt @@ -0,0 +1,104 @@ +/* + * 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, + minColours: Int = 20, + 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 > minColours, "$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() } + + // 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() } + + @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() } + + @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/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..a781eb4722 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserChromeHost.kt @@ -0,0 +1,425 @@ +/* + * 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.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 +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() {} + } + + /** 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, + 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 accessInfo by mutableStateOf<AccessInfo?>(null) + 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) + } + + /** "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 + 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) + }, + ) + } + } + 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) { + 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/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/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..7a31abd958 --- /dev/null +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/BrowserWebTools.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.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.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.amethyst.commons.browser.ui.pill.CertificateInfo +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 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(), + ) + } + + // ---- 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, + ) { + 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/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 8780d82a73..a32f8f27d1 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -20,31 +20,45 @@ */ package com.vitorpamplona.amethyst.napplethost +import android.Manifest +import android.app.ActivityManager 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 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 @@ -52,16 +66,29 @@ 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.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 @@ -75,41 +102,73 @@ 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 + * ([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. */ 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 + + // 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%. 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 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 + + // 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: 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 // registerForActivityResult requires. This activity hosts its WebView directly, so it can run the @@ -118,6 +177,16 @@ 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 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 @@ -154,20 +223,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() + chrome?.handleBack() == true -> Unit + 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 || chrome?.wantsBack == true || webView?.canGoBack() == true } private val brokerConnection = @@ -189,15 +268,34 @@ class NappletBrowserActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + SandboxComposeResources.ensure(this) - 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() @@ -205,61 +303,78 @@ 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 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, - ), - ) - // 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 - // 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.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) + } + 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 @@ -275,22 +390,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) @@ -306,27 +417,37 @@ class NappletBrowserActivity : ComponentActivity() { // A picker still up when the browser is torn down would otherwise leave its callback unanswered. pendingFileChooser.cancel() fileChooserLauncher.teardown() - 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() - } + // A dialog still up would leak its window and leave the page's JS blocked on an unanswered result. + chrome?.dialog?.answer?.invoke(false, null, false) + chrome?.permissionPrompt?.answer?.invoke(false, false) + 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) } } @@ -343,43 +464,26 @@ 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() } ?: 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() } } - /** Captures favicon and console output, drives the top loading bar, and opens the file picker. */ + 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() { /** * Without this override the base implementation returns false and WebView shows nothing at all, so @@ -399,11 +503,22 @@ class NappletBrowserActivity : ComponentActivity() { updateLoadProgress(newProgress) } + override fun onReceivedTitle( + view: WebView, + title: String?, + ) { + pageTitle = title?.trim()?.takeIf { it.isNotEmpty() && it != view.url } + updateUi { copy(title = pageTitle ?: BrowserChrome.displayHost(chrome.url)) } + 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 @@ -412,16 +527,97 @@ 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. The chrome shows them itself. + override fun onJsAlert( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = showJsDialog(PageDialogType.ALERT, url, message, null, result) + + override fun onJsConfirm( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): Boolean = showJsDialog(PageDialogType.CONFIRM, url, message, null, result) + + override fun onJsPrompt( + view: WebView, + url: String?, + message: String?, + defaultValue: String?, + result: JsPromptResult, + ): Boolean = showJsDialog(PageDialogType.PROMPT, url, message, defaultValue, result) + + override fun onJsBeforeUnload( + view: WebView, + url: String?, + message: String?, + result: JsResult, + ): 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 + * Chrome tab. Without a user gesture it's refused (Chrome's popup blocker). + */ + override fun onCreateWindow( + view: WebView, + 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) { + chrome?.permissionPrompt = null + } + + 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() { + chrome?.permissionPrompt = null + } + + override fun onShowCustomView( + view: View, + callback: CustomViewCallback, + ) = enterFullscreen(view, callback) + + override fun onHideCustomView() = exitFullscreen() } /** @@ -452,10 +648,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( @@ -466,9 +659,21 @@ 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 - controlSheet?.updateUrl(url) + 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. + jsDialogsOnPage = 0 + jsDialogsBlocked = false + updateChromeState { copy(isLoading = true) } + showUrl(url) } override fun onReceivedError( @@ -497,7 +702,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( @@ -505,22 +710,75 @@ class NappletBrowserActivity : ComponentActivity() { url: String, isReload: Boolean, ) { - syncBackState() - controlSheet?.updateUrl(url) + syncNavigation(view) + showUrl(url) } override fun onPageFinished( view: WebView, url: String, ) { - syncBackState() - controlSheet?.updateUrl(url) + syncNavigation(view) + 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://"))) { recordHistory(url, view.title) 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() + updateChromeState { copy(canGoBack = view.canGoBack(), canGoForward = view.canGoForward()) } + } + + /** + * 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) { + 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 = + Message.obtain(null, NappletIpc.MSG_QUERY_WEB_FAVORITE).apply { + replyTo = replyMessenger + data = Bundle().apply { putString(NappletIpc.KEY_FAVORITE_URL, url) } + } + queueToBroker(msg) } /** @@ -535,7 +793,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 @@ -557,7 +815,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). */ @@ -567,12 +825,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() @@ -594,17 +847,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) + updateChromeState { copy(torOn = true) } } - if (this::webView.isInitialized) webView.loadUrl(resolved.url) + webView?.loadUrl(resolved.url) } // ---- bridge: page <-> native (mirror of NappletBrowserService.onBridgeMessage) ---- @@ -621,6 +875,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 "" @@ -639,13 +899,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 = @@ -653,7 +934,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) { @@ -677,6 +963,19 @@ 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 + 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 + 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 @@ -692,6 +991,289 @@ 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) { 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) + } + } + } + } + + 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) + } + + /** + * 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: (allow: Boolean?, grantNow: Boolean) -> Unit, + ) { + val host = chrome + if (host == null || isFinishing || isDestroyed || host.permissionPrompt != null) { + answer(null, false) + return + } + 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) + } + } + } + + /** 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 + // 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 >= ICON_BUILDER_SDK) { + 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. @@ -713,11 +1295,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() + 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 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 = @@ -736,26 +1318,178 @@ 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 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, - 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) }, - isFavoriteInitially = intent.getBooleanExtra(EXTRA_IS_FAVORITE, false), - onFavoriteToggle = { url, _ -> sendFavoriteToggle(url) }, - ).also { controlSheet = it } + 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), + defaultBrowserName = DefaultBrowser.label(this), + ), + 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 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) } + } + BrowserPillEvent.CopyOrigin -> BrowserWebTools.copyToClipboard(this@NappletBrowserActivity, currentUrl()) + BrowserPillEvent.PageInfo -> showPageInfo() + BrowserPillEvent.Close -> finish() + } + } + + override fun onFind(query: String) { + val wv = webView ?: return + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } + + override fun onFindNext(forward: Boolean) { + webView?.findNext(forward) + } + + 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 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 + } + } + + /** + * 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 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. */ + 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 @@ -763,8 +1497,7 @@ class NappletBrowserActivity : ComponentActivity() { * 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 "" @@ -772,33 +1505,31 @@ 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) } - 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 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 = - NappletConsolePanel(this).also { - it.onClearCallback = { controlSheet?.updateConsoleCount(0) } - consolePanel = it - } - private fun buildLoadingView(): View = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL @@ -817,6 +1548,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%. @@ -834,9 +1599,11 @@ class NappletBrowserActivity : ComponentActivity() { private fun updateLoadProgress(progress: Int) { if (progress >= 100) { topProgressBar.visibility = View.GONE + updateUi { copy(loadProgress = null, chrome = chrome.copy(isLoading = false)) } } else { topProgressBar.progress = progress topProgressBar.visibility = View.VISIBLE + updateUi { copy(loadProgress = progress / 100f) } } } @@ -845,13 +1612,11 @@ 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 { - 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 } @@ -862,6 +1627,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 @@ -878,6 +1644,10 @@ 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" + + /** `TaskDescription.Builder.setIcon(Icon)` exists from this SDK on. */ + private const val ICON_BUILDER_SDK = 37 fun intent( context: Context, @@ -890,7 +1660,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) @@ -902,5 +1672,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 4725eb7074..f814830628 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 /** @@ -113,6 +116,96 @@ 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's certificate for page info; answered with [MSG_PAGE_INFO]. */ + const val MSG_PAGE_INFO_REQUEST = 25 + + /** + * 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 + + /** + * 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_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" + 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" @@ -152,6 +245,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..e58e8a861e 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,82 @@ 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 + val certificate = BrowserWebTools.certificateInfo(wv) + sendToClient(tab, NappletBrowserContract.MSG_PAGE_INFO) { + 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 -> { + 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 +391,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 +418,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( @@ -386,6 +626,97 @@ 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) + + // 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( @@ -462,22 +793,22 @@ 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 - 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) } @@ -507,6 +838,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. */ @@ -530,14 +890,19 @@ 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()) + putBoolean(NappletBrowserContract.KEY_CAN_GO_FORWARD, view.canGoForward()) + title?.let { putString(NappletBrowserContract.KEY_TITLE, it) } } } runCatching { tab?.clientMessenger?.send(message) } @@ -557,21 +922,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) @@ -596,6 +953,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/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 28539b2af2..0000000000 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletControlSheet.kt +++ /dev/null @@ -1,527 +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.util.TypedValue -import android.view.Gravity -import android.view.MotionEvent -import android.view.View -import android.view.inputmethod.EditorInfo -import android.widget.EditText -import android.widget.ImageView -import android.widget.LinearLayout -import android.widget.Switch -import android.widget.TextView -import androidx.core.content.ContextCompat -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). - * - * 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. - */ -@SuppressLint("UseSwitchCompatOrMaterialCode") // plain framework Switch: :nappletHost is Compose/Material-free -class NappletControlSheet( - context: Context, - private val 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. - isFavoriteInitially: Boolean = false, - private val onFavoriteToggle: ((url: String, isFavorite: Boolean) -> Unit)? = null, -) : 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 torOn = torInitiallyOn - private var currentUrl = liveUrl - private var isFavorite = isFavoriteInitially - private var consoleShowing = false - - private val panel: LinearLayout - 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 - - init { - orientation = VERTICAL - gravity = Gravity.CENTER_HORIZONTAL - - panel = buildPanel().also { addView(it) } - addView(buildGrabber()) - } - - 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)) - - 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) - }, - ) - } - } - - private fun titleRow(): 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 - setPadding(dp(10), 0, 0, 0) - }, - ) - } - - /** - * 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 - val field = - EditText(context).apply { - setText(initial) - 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) - 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 - } - } - } - 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) - } - } - - /** 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. */ - fun updateUrl(url: String) { - 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) - } - } - - 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) - 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 = - LinearLayout(context).apply { - orientation = HORIZONTAL - gravity = Gravity.CENTER_VERTICAL - // Same vertical rhythm as the Tor row and the Compose twin's rows. - setPadding(dp(8), dp(10), dp(8), dp(10)) - isClickable = true - setOnClickListener { onClick() } - addView( - TextView(context).apply { - text = glyph - setTextColor(dimmed) - textSize = 18f - width = dp(28) - gravity = Gravity.CENTER - }, - ) - addView( - TextView(context).apply { - text = label - setTextColor(onSurface) - textSize = 15f - setPadding(dp(8), 0, 0, 0) - }, - ) - } - - private fun divider(): View = - View(context).apply { - setBackgroundColor(dimmed and 0x33FFFFFF.toInt()) - layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, dp(1)) - } - - /** 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 - // 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 - } - 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 - } - } - } - } - - 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() -} 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 fd5646c0a8..d1eb8f8e4f 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -21,11 +21,11 @@ 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 import android.content.res.ColorStateList +import android.content.res.Configuration import android.net.Uri import android.os.Bundle import android.os.Handler @@ -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,11 @@ 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.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 @@ -192,9 +198,11 @@ 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 + // 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 // Set once the WebView has begun loading the shell, so a retry doesn't reload it. private var started = false @@ -204,7 +212,9 @@ class NappletHostActivity : ComponentActivity() { private val backCallback = object : OnBackPressedCallback(false) { override fun handleOnBackPressed() { - if (this@NappletHostActivity::webView.isInitialized && 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 @@ -215,7 +225,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 || chrome?.wantsBack == true } // True between onResume and onPause. Sent to the broker (foreground hold) on connect too, in case @@ -247,6 +258,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() @@ -284,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)) @@ -311,28 +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, - ), - ) - // 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 @@ -378,7 +374,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 +388,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 +461,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 +640,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 + chrome?.closeFind() + (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, @@ -686,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 } } @@ -716,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)) } } } @@ -727,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 ---- @@ -902,25 +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, - 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) }, - ).also { controlSheet = it } + 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, + ), + ), + 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() + 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 onFind(query: String) { + val wv = liveWebView() ?: return + if (query.isEmpty()) wv.clearMatches() else wv.findAllAsync(query) + } + + override fun onFindNext(forward: Boolean) { + liveWebView()?.findNext(forward) + } + + override fun onFindClosed() { + liveWebView()?.clearMatches() + } + + 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 @@ -935,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 @@ -973,18 +1047,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 5c73f25c81..998117aad8 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,11 @@ 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.JsPromptResult import android.webkit.JsResult +import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient import android.webkit.WebResourceError @@ -47,6 +50,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 @@ -57,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 @@ -113,6 +118,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 @@ -123,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) }) } @@ -185,7 +196,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. @@ -197,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 @@ -311,10 +343,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 @@ -342,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 @@ -357,6 +395,7 @@ class NappletHostService : Service() { tab.contentServer = null tab.webView?.destroy() tab.webView = null + tab.container = null } @Suppress("SetJavaScriptEnabled") @@ -405,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. @@ -526,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 @@ -533,6 +578,35 @@ 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 + * 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, @@ -557,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/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 8611a0d8cc..3aeafdf70b 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,42 @@ 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 + + /** + * 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" @@ -144,6 +181,18 @@ 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" + + /** 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/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}") + } + } +} diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index 34c48f60bf..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> @@ -36,4 +17,5 @@ <!-- 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> + </resources>