Merge pull request #4211 from vitorpamplona/claude/relaxed-maxwell-hlc0a2

feat(browser): Chrome-PWA parity and a shared Compose chrome for every browser surface
This commit is contained in:
Vitor Pamplona
2026-09-26 21:48:19 -04:00
committed by GitHub
64 changed files with 8999 additions and 1860 deletions
@@ -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` / `<meta name="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 (`<a download>`, `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), `<input type=file capture>`.
- 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 <app>" 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 <app>" 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 `<meta name="theme-color">` (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.
@@ -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.
+37
View File
@@ -17,6 +17,17 @@
<action android:name="android.intent.action.TTS_SERVICE" />
</intent>
<!-- So the browser's hand-off tile can name the browser it would open.
Android 11+ package visibility answers resolveActivity with nothing
for a scheme that is not declared here, so without this the tile can
only ever say "Open in browser". Read-only: it names the target, the
hand-off itself still goes through a chooser. -->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>
<!-- NIP-A3 payment targets. Android 11+ package visibility means
queryIntentActivities returns NOTHING for a scheme not declared here,
so without these the zap picker's pay-to chip is invisible on every
@@ -640,6 +651,22 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!--
Compose Resources learns its Context from a ContentProvider the library
declares, and a provider is instantiated only in the process it belongs
to. The browser chrome is now drawn by shared composables that read
`Res.string`, and those run in `:napplet` — where the lookup threw
MissingResourceException ("Android context is not initialized") and killed
the sandbox. `multiprocess` is the platform's answer to exactly this: it
gives every process of the app its own instance of the provider. A second
<provider> element cannot work, because the manifest merger keys them by
android:name and would merge the two into one.
-->
<provider
android:name="org.jetbrains.compose.resources.AndroidContextProvider"
android:multiprocess="true"
tools:node="merge" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
@@ -665,6 +692,16 @@
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst" />
<!-- Target of web-app launcher shortcuts (Add to Home screen + favorites). Main process, so the
app and Tor are up before it hands the URL to the full-screen browser; draws nothing. -->
<activity
android:name=".favorites.WebShortcutActivity"
android:exported="false"
android:excludeFromRecents="true"
android:noHistory="true"
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
@@ -23,9 +23,12 @@ package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.service.http.HttpClientEnvironment
import com.vitorpamplona.amethyst.commons.service.http.MediaCallEventListener
import com.vitorpamplona.amethyst.favorites.WebShortcuts
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplet.WebSitePermissionRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.service.okhttp.isEmulator
@@ -35,6 +38,8 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.io.File
@@ -142,6 +147,17 @@ class Amethyst : Application() {
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
instance.favoriteApps.init()
// Mirror web-app favorites into the launcher's long-press shortcuts (open in Amethyst's browser).
CoroutineScope(Dispatchers.Default).launch {
instance.favoriteApps.favorites
.map { apps -> apps.filterIsInstance<FavoriteApp.WebApp>().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()
@@ -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)
}
}
@@ -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<FavoriteApp>,
) {
val shortcuts =
favorites
.filterIsInstance<FavoriteApp.WebApp>()
.take(MAX_DYNAMIC)
.map { build(context, DYNAMIC_PREFIX + idOf(it.url), it.url, it.label) }
runCatching {
val stale =
ShortcutManagerCompat
.getDynamicShortcuts(context)
.map { it.id }
.filter { it.startsWith(DYNAMIC_PREFIX) && it !in shortcuts.map { s -> s.id } }
if (stale.isNotEmpty()) ShortcutManagerCompat.removeDynamicShortcuts(context, stale)
shortcuts.forEach { ShortcutManagerCompat.pushDynamicShortcut(context, it) }
}.onFailure { Log.w(TAG, "Could not publish favorite shortcuts", it) }
}
private fun build(
context: Context,
id: String,
url: String,
title: String,
): ShortcutInfoCompat {
val label = title.ifBlank { BrowserChrome.displayHost(url) }
return ShortcutInfoCompat
.Builder(context, id)
.setShortLabel(label.take(24))
.setLongLabel(label.take(48))
.setIcon(iconFor(context, url))
.setIntent(WebShortcutActivity.intent(context, url))
.build()
}
private fun iconFor(
context: Context,
url: String,
): IconCompat {
val bitmap =
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) }
}
@@ -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,
@@ -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<Preferences>
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 `"<origin>|<permission>"` keys.
*/
object WebSitePermissionRegistry {
private val _decisions = MutableStateFlow<Map<String, Map<BrowserSitePermission, Decision>>>(emptyMap())
/** origin → permission → decision (only answered permissions appear). */
val decisions: StateFlow<Map<String, Map<BrowserSitePermission, Decision>>> = _decisions.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val loaded = mutableMapOf<String, MutableMap<BrowserSitePermission, Decision>>()
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
}
}
}
}
@@ -745,7 +745,11 @@ private fun RecentRow(
overflow = TextOverflow.Ellipsis,
)
Text(
entry.host,
// Host and path, not just the host: two pages of one site usually carry the
// same <title>, so `primal.net/home` and `primal.net` arrived as two rows
// reading "Primal / primal.net" and there was no way to tell them apart or
// to know which one a tap would open.
remember(entry.url, entry.host) { recentSubtitle(entry.url, entry.host) },
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
@@ -818,3 +822,20 @@ private fun SiteIcon(
/** The host of [url] for a favorite's default label, falling back to the raw string. */
private fun hostOf(url: String): String = OmniboxInput.hostOf(url) ?: url
/**
* The second line of a Recent row: what a reader needs to tell two rows of one site apart.
*
* The scheme and a bare trailing slash are noise at this size, so they go; everything after the
* host stays, because that is the part that differs. Falls back to the host when the URL has
* nothing more to say.
*/
internal fun recentSubtitle(
url: String,
host: String,
): String {
val schemeEnd = url.indexOf("://")
val afterScheme = if (schemeEnd > 0) url.substring(schemeEnd + 3) else url
val trimmed = afterScheme.removeSuffix("/")
return trimmed.ifBlank { host }
}
@@ -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>,
)
@@ -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) }
@@ -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
@@ -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"
}
@@ -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
}
@@ -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() },
)
@@ -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(')')
}
}
@@ -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,
)
@@ -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() })
}
}
@@ -35,19 +35,27 @@ import android.os.Message
import android.os.Messenger
import android.os.SystemClock
import androidx.annotation.RequiresApi
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import java.util.concurrent.atomic.AtomicLong
@@ -67,7 +75,9 @@ class EmbeddedNostrAppController(
private val params: Bundle,
) : EmbeddedSurfaceController,
EmbeddedImeBridge,
EmbeddedMagnifierProbe {
EmbeddedMagnifierProbe,
ConsoleBridge,
FindBridge {
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
private var serviceMessenger: Messenger? = null
private var bound = false
@@ -112,6 +122,14 @@ class EmbeddedNostrAppController(
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
/** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */
override val consoleLogs = mutableStateListOf<ConsoleLine>()
override fun clearConsoleLogs() = consoleLogs.clear()
private val _findResult = mutableStateOf<FindResult?>(null)
override val findResult: State<FindResult?> = _findResult
private val connection =
object : ServiceConnection {
override fun onServiceConnected(
@@ -266,6 +284,22 @@ class EmbeddedNostrAppController(
}
}
}
NappletEmbedContract.MSG_FIND_RESULT -> {
val data = msg.data ?: return true
_findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL))
}
NappletEmbedContract.MSG_CONSOLE_LOG -> {
val data = msg.data ?: return true
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
consoleLogs.add(
ConsoleLine(
consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()),
data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(),
data.getString(NappletEmbedContract.KEY_CONSOLE_SOURCE).orEmpty(),
data.getInt(NappletEmbedContract.KEY_CONSOLE_LINE, 0),
),
)
}
NappletEmbedContract.MSG_MAGNIFIER_FRAME -> {
val data = msg.data ?: return true
val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true
@@ -305,6 +339,15 @@ class EmbeddedNostrAppController(
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
override fun find(query: String) {
if (query.isEmpty()) _findResult.value = null
send(NappletEmbedContract.MSG_FIND) { putString(NappletEmbedContract.KEY_FIND_QUERY, query) }
}
override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) }
fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
/** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */
override fun retry() {
hasLoadedReal = false
@@ -353,5 +396,7 @@ class EmbeddedNostrAppController(
private companion object {
private val SESSION_SEQ = AtomicLong()
private const val MAX_CONSOLE_LOGS = 200
}
}
@@ -26,18 +26,19 @@ import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -50,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
@@ -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(
@@ -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"))
}
}
@@ -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>
@@ -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+)*"""), "")
}
@@ -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
}
@@ -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 }
}
}
@@ -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),
)
}
}
@@ -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"))
}
}
@@ -5456,6 +5456,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>
@@ -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)) }
}
}
}
}
@@ -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)
}
}
}
@@ -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)
@@ -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() })
}
}
}
@@ -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 }
}
@@ -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 = {},
)
}
}
}
@@ -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),
)
}
}
}
@@ -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))
}
}
}
}
@@ -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) },
)
}
}
}
}
@@ -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)
@@ -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)
}
}
@@ -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")
@@ -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))
}
}
@@ -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
}
}
+7
View File
@@ -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)
@@ -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
}
}
}
@@ -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() }
}
@@ -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()
}
@@ -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) }
}
@@ -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
}
@@ -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"
}
@@ -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
@@ -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 =
@@ -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) {
@@ -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
}
}
@@ -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()
}
@@ -115,6 +115,34 @@ object NappletEmbedContract {
*/
const val MSG_FILE_CHOOSER_RESULT = 19
/** Client → provider: find [KEY_FIND_QUERY] in the page; an empty query clears the highlights. */
const val MSG_FIND = 20
/** Client → provider: move to the next ([KEY_FIND_FORWARD] true) or previous match. */
const val MSG_FIND_NEXT = 21
/** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */
const val MSG_FIND_RESULT = 22
/** Client → provider: set the page's text size to [KEY_TEXT_ZOOM] percent. */
const val MSG_SET_TEXT_ZOOM = 23
/**
* Provider → client: one line for the developer console — [KEY_CONSOLE_LEVEL] (WebView's
* `ConsoleMessage.MessageLevel` name), [KEY_CONSOLE_MESSAGE], [KEY_CONSOLE_SOURCE], [KEY_CONSOLE_LINE].
*/
const val MSG_CONSOLE_LOG = 24
const val KEY_FIND_QUERY = "findQuery"
const val KEY_FIND_FORWARD = "findForward"
const val KEY_FIND_ACTIVE = "findActive"
const val KEY_FIND_TOTAL = "findTotal"
const val KEY_TEXT_ZOOM = "textZoom"
const val KEY_CONSOLE_LEVEL = "consoleLevel"
const val KEY_CONSOLE_MESSAGE = "consoleMessage"
const val KEY_CONSOLE_SOURCE = "consoleSource"
const val KEY_CONSOLE_LINE = "consoleLine"
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
@@ -21,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,
),
)
}
/**
@@ -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,
@@ -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) {
@@ -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"
@@ -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}")
}
}
}
+2 -20
View File
@@ -4,30 +4,11 @@
<string name="napplet_invalid">Invalid nApplet.</string>
<string name="napplet_webview_too_old">This device\'s WebView is too old to run nApplets safely.</string>
<!-- Trusted chrome (top bar + live action notices) -->
<string name="napplet_chrome_access_title">What “%1$s” can access</string>
<string name="napplet_chrome_keys_safe">It can never read your keys, and every sign, publish, upload, or payment was approved by you. Manage access in Settings ▸ nApplets.</string>
<string name="napplet_chrome_static_site">Static site — it has no special access to your account.</string>
<string name="napplet_chrome_permissions_desc">What this app can access</string>
<string name="napplet_chrome_manage_permissions">Manage permissions</string>
<string name="napplet_chrome_reload">Reload</string>
<!-- Browser address bar and developer console strings are in :commons -->
<!-- Live "allow always" action notices -->
<string name="napplet_action_published">“%1$s” published a note as you</string>
<string name="napplet_action_uploaded">“%1$s” uploaded a file</string>
<string name="napplet_action_paid">“%1$s” made a payment</string>
<!-- nSite network routing (Tor vs open web) -->
<string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string>
<string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string>
<!-- Short labels for the pull-down sheet's network row -->
<string name="napplet_net_tor_label">Loads over Tor</string>
<string name="napplet_net_open_label">Loads over the open web</string>
<!-- Favorite toggle in the pull-down sheet -->
<string name="browser_favorite_add">Add to favorites</string>
<string name="browser_favorite_remove">Remove from favorites</string>
<!-- Loading / unavailable screens -->
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>
@@ -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>