mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
+115
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+42
@@ -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>,
|
||||
)
|
||||
+157
-7
@@ -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) }
|
||||
|
||||
+268
-27
@@ -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
|
||||
|
||||
|
||||
-237
@@ -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"
|
||||
}
|
||||
+13
-9
@@ -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
|
||||
}
|
||||
|
||||
+17
-23
@@ -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() },
|
||||
)
|
||||
|
||||
+109
-44
@@ -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(')')
|
||||
}
|
||||
}
|
||||
|
||||
+43
@@ -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,
|
||||
)
|
||||
-259
@@ -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() })
|
||||
}
|
||||
}
|
||||
+46
-1
@@ -35,19 +35,27 @@ import android.os.Message
|
||||
import android.os.Messenger
|
||||
import android.os.SystemClock
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.privacysandbox.ui.client.SandboxedUiAdapterFactory
|
||||
import androidx.privacysandbox.ui.client.view.SandboxedSdkView
|
||||
import androidx.privacysandbox.ui.core.SandboxedUiAdapter
|
||||
import com.vitorpamplona.amethyst.commons.browser.ui.pill.ConsoleLine
|
||||
import com.vitorpamplona.amethyst.napplet.NappletWebViewProfiles
|
||||
import com.vitorpamplona.amethyst.napplet.WebFileChooserCoordinator
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletEmbedContract
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ConsoleBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedImeBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedLoadStatus
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProbe
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindBridge
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FindResult
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.consoleLevelOf
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -67,7 +75,9 @@ class EmbeddedNostrAppController(
|
||||
private val params: Bundle,
|
||||
) : EmbeddedSurfaceController,
|
||||
EmbeddedImeBridge,
|
||||
EmbeddedMagnifierProbe {
|
||||
EmbeddedMagnifierProbe,
|
||||
ConsoleBridge,
|
||||
FindBridge {
|
||||
private val incoming = Messenger(Handler(Looper.getMainLooper(), ::onServiceMessage))
|
||||
private var serviceMessenger: Messenger? = null
|
||||
private var bound = false
|
||||
@@ -112,6 +122,14 @@ class EmbeddedNostrAppController(
|
||||
|
||||
override var onMagnifierFrame: ((MagnifierFrame) -> Unit)? = null
|
||||
|
||||
/** The app's console output, capped at [MAX_CONSOLE_LOGS] entries. */
|
||||
override val consoleLogs = mutableStateListOf<ConsoleLine>()
|
||||
|
||||
override fun clearConsoleLogs() = consoleLogs.clear()
|
||||
|
||||
private val _findResult = mutableStateOf<FindResult?>(null)
|
||||
override val findResult: State<FindResult?> = _findResult
|
||||
|
||||
private val connection =
|
||||
object : ServiceConnection {
|
||||
override fun onServiceConnected(
|
||||
@@ -266,6 +284,22 @@ class EmbeddedNostrAppController(
|
||||
}
|
||||
}
|
||||
}
|
||||
NappletEmbedContract.MSG_FIND_RESULT -> {
|
||||
val data = msg.data ?: return true
|
||||
_findResult.value = FindResult(data.getInt(NappletEmbedContract.KEY_FIND_ACTIVE), data.getInt(NappletEmbedContract.KEY_FIND_TOTAL))
|
||||
}
|
||||
NappletEmbedContract.MSG_CONSOLE_LOG -> {
|
||||
val data = msg.data ?: return true
|
||||
if (consoleLogs.size >= MAX_CONSOLE_LOGS) consoleLogs.removeAt(0)
|
||||
consoleLogs.add(
|
||||
ConsoleLine(
|
||||
consoleLevelOf(data.getString(NappletEmbedContract.KEY_CONSOLE_LEVEL).orEmpty()),
|
||||
data.getString(NappletEmbedContract.KEY_CONSOLE_MESSAGE).orEmpty(),
|
||||
data.getString(NappletEmbedContract.KEY_CONSOLE_SOURCE).orEmpty(),
|
||||
data.getInt(NappletEmbedContract.KEY_CONSOLE_LINE, 0),
|
||||
),
|
||||
)
|
||||
}
|
||||
NappletEmbedContract.MSG_MAGNIFIER_FRAME -> {
|
||||
val data = msg.data ?: return true
|
||||
val bytes = data.getByteArray(NappletEmbedContract.KEY_MAG_BYTES) ?: return true
|
||||
@@ -305,6 +339,15 @@ class EmbeddedNostrAppController(
|
||||
|
||||
fun reload() = send(NappletEmbedContract.MSG_RELOAD)
|
||||
|
||||
override fun find(query: String) {
|
||||
if (query.isEmpty()) _findResult.value = null
|
||||
send(NappletEmbedContract.MSG_FIND) { putString(NappletEmbedContract.KEY_FIND_QUERY, query) }
|
||||
}
|
||||
|
||||
override fun findNext(forward: Boolean) = send(NappletEmbedContract.MSG_FIND_NEXT) { putBoolean(NappletEmbedContract.KEY_FIND_FORWARD, forward) }
|
||||
|
||||
fun setTextZoom(percent: Int) = send(NappletEmbedContract.MSG_SET_TEXT_ZOOM) { putInt(NappletEmbedContract.KEY_TEXT_ZOOM, percent) }
|
||||
|
||||
/** User-triggered recovery for a stuck or failed session: reload the verified content from scratch. */
|
||||
override fun retry() {
|
||||
hasLoadedReal = false
|
||||
@@ -353,5 +396,7 @@ class EmbeddedNostrAppController(
|
||||
|
||||
private companion object {
|
||||
private val SESSION_SEQ = AtomicLong()
|
||||
|
||||
private const val MAX_CONSOLE_LOGS = 200
|
||||
}
|
||||
}
|
||||
|
||||
+77
-53
@@ -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
|
||||
|
||||
+77
@@ -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>
|
||||
|
||||
+261
@@ -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+)*"""), "")
|
||||
}
|
||||
+32
-4
@@ -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
|
||||
}
|
||||
|
||||
+43
@@ -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 }
|
||||
}
|
||||
}
|
||||
+175
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
+69
@@ -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"))
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -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>
|
||||
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.commons.browser.ui.pill
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.browser.BrowserChrome
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_keys_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_manage
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_napplet
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_none_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_access_nsite
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_connection
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_open
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_info_tor
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_ok
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_off
|
||||
import com.vitorpamplona.amethyst.commons.resources.browser_pill_tor_on
|
||||
import com.vitorpamplona.amethyst.commons.ui.stringRes
|
||||
|
||||
/**
|
||||
* "What it can access" for a sandboxed nSite or nApplet: the capabilities it was launched with, how it
|
||||
* reaches the network (nSites only), and the promise that the keys stay in Amethyst. The sandboxed
|
||||
* counterpart of [PageInfoSheet], drawn with the same header and grouped cards.
|
||||
*
|
||||
* [capabilities] are already-localized labels; empty means a static site with no special access.
|
||||
* [torOn] is null when the app has no network route of its own to show.
|
||||
*/
|
||||
@Composable
|
||||
fun AccessInfoSheet(
|
||||
title: String,
|
||||
isWebsite: Boolean,
|
||||
capabilities: List<String>,
|
||||
torOn: Boolean?,
|
||||
onManagePermissions: (() -> Unit)?,
|
||||
onDone: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(28.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
shadowElevation = 6.dp,
|
||||
border = PillDefaults.hairline(),
|
||||
) {
|
||||
Column(
|
||||
Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(PillDefaults.SheetPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SiteMonogram(title, size = 48.dp)
|
||||
Spacer(Modifier.width(14.dp))
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
SecurityIcon(BrowserChrome.Security.SANDBOX, size = 14.dp)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
stringRes(if (isWebsite) Res.string.browser_pill_access_nsite else Res.string.browser_pill_access_napplet),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GroupCard(heading = stringRes(Res.string.browser_pill_access)) {
|
||||
if (capabilities.isEmpty()) {
|
||||
GroupRow(
|
||||
icon = { Icon(MaterialSymbols.Shield, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||
iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
title = stringRes(Res.string.browser_pill_access_none),
|
||||
supporting = stringRes(Res.string.browser_pill_access_none_desc),
|
||||
onClick = null,
|
||||
)
|
||||
} else {
|
||||
capabilities.forEachIndexed { index, label ->
|
||||
if (index > 0) GroupDivider()
|
||||
GroupRow(
|
||||
icon = { Icon(MaterialSymbols.CheckCircle, contentDescription = null, tint = MaterialTheme.colorScheme.onSecondaryContainer) },
|
||||
iconContainer = MaterialTheme.colorScheme.secondaryContainer,
|
||||
title = label,
|
||||
supporting = null,
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
GroupDivider()
|
||||
GroupRow(
|
||||
icon = { Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant) },
|
||||
iconContainer = MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
title = stringRes(Res.string.browser_pill_access_desc),
|
||||
supporting = stringRes(Res.string.browser_pill_access_keys_desc),
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
|
||||
torOn?.let { tor ->
|
||||
GroupCard(heading = stringRes(Res.string.browser_pill_info_connection)) {
|
||||
GroupRow(
|
||||
icon = { PillActionIcon(BrowserChrome.Action.TOR, tint = if (tor) MaterialTheme.colorScheme.onTertiaryContainer else MaterialTheme.colorScheme.onSurfaceVariant, size = 22.dp) },
|
||||
iconContainer = if (tor) MaterialTheme.colorScheme.tertiaryContainer else MaterialTheme.colorScheme.surfaceContainerHighest,
|
||||
title = stringRes(if (tor) Res.string.browser_pill_info_tor else Res.string.browser_pill_info_open),
|
||||
supporting = stringRes(if (tor) Res.string.browser_pill_tor_on else Res.string.browser_pill_tor_off),
|
||||
onClick = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) {
|
||||
if (onManagePermissions != null) {
|
||||
TextButton(onClick = onManagePermissions) { Text(stringRes(Res.string.browser_pill_access_manage)) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Button(onClick = onDone) { Text(stringRes(Res.string.browser_pill_ok)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+227
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
+548
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
+100
@@ -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 }
|
||||
}
|
||||
+257
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+238
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+135
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+476
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+355
@@ -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)
|
||||
+215
@@ -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)
|
||||
}
|
||||
}
|
||||
+6
@@ -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")
|
||||
|
||||
+80
@@ -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))
|
||||
}
|
||||
}
|
||||
+104
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
|
||||
+425
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+207
@@ -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() }
|
||||
}
|
||||
+175
@@ -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) }
|
||||
}
|
||||
+388
@@ -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"
|
||||
}
|
||||
+976
-192
File diff suppressed because it is too large
Load Diff
+95
-1
@@ -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
|
||||
|
||||
+428
-54
@@ -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 =
|
||||
|
||||
+12
-9
@@ -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) {
|
||||
|
||||
-302
@@ -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
|
||||
}
|
||||
}
|
||||
-527
@@ -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()
|
||||
}
|
||||
+28
@@ -115,6 +115,34 @@ object NappletEmbedContract {
|
||||
*/
|
||||
const val MSG_FILE_CHOOSER_RESULT = 19
|
||||
|
||||
/** Client → provider: find [KEY_FIND_QUERY] in the page; an empty query clears the highlights. */
|
||||
const val MSG_FIND = 20
|
||||
|
||||
/** Client → provider: move to the next ([KEY_FIND_FORWARD] true) or previous match. */
|
||||
const val MSG_FIND_NEXT = 21
|
||||
|
||||
/** Provider → client: [KEY_FIND_ACTIVE] (0-based) of [KEY_FIND_TOTAL] matches. */
|
||||
const val MSG_FIND_RESULT = 22
|
||||
|
||||
/** Client → provider: set the page's text size to [KEY_TEXT_ZOOM] percent. */
|
||||
const val MSG_SET_TEXT_ZOOM = 23
|
||||
|
||||
/**
|
||||
* Provider → client: one line for the developer console — [KEY_CONSOLE_LEVEL] (WebView's
|
||||
* `ConsoleMessage.MessageLevel` name), [KEY_CONSOLE_MESSAGE], [KEY_CONSOLE_SOURCE], [KEY_CONSOLE_LINE].
|
||||
*/
|
||||
const val MSG_CONSOLE_LOG = 24
|
||||
|
||||
const val KEY_FIND_QUERY = "findQuery"
|
||||
const val KEY_FIND_FORWARD = "findForward"
|
||||
const val KEY_FIND_ACTIVE = "findActive"
|
||||
const val KEY_FIND_TOTAL = "findTotal"
|
||||
const val KEY_TEXT_ZOOM = "textZoom"
|
||||
const val KEY_CONSOLE_LEVEL = "consoleLevel"
|
||||
const val KEY_CONSOLE_MESSAGE = "consoleMessage"
|
||||
const val KEY_CONSOLE_SOURCE = "consoleSource"
|
||||
const val KEY_CONSOLE_LINE = "consoleLine"
|
||||
|
||||
const val KEY_FILE_CHOOSER_ID = "fileChooserId"
|
||||
const val KEY_FILE_CHOOSER_ACCEPT = "fileChooserAccept"
|
||||
const val KEY_FILE_CHOOSER_MULTIPLE = "fileChooserMultiple"
|
||||
|
||||
+143
-72
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+111
-1
@@ -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,
|
||||
|
||||
+12
-9
@@ -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"
|
||||
|
||||
|
||||
+59
@@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user