Sweep of Kotlin compiler warnings in every module's main source sets
(quartz, commons, cli, desktopApp, amethyst, nappletHost).
Genuine code fixes:
- Drop unnecessary !!/safe-calls and redundant elvis/casts (OkHttp's
now-non-null `body`, smart-cast callbacks, non-null String receivers).
- Remove provably-redundant conditions (`canvas == null` after a
non-null content check; `account != null` implied by `canModerate`).
- Migrate deprecated kotlinx.collections.immutable persistent ops
(add/remove/put/addAll -> adding/removing/putting/addingAll).
- Migrate LocalClipboardManager -> LocalClipboard (+ scoped setText),
ContextCompat.startActivity -> context.startActivity, TabRow ->
SecondaryTabRow, and @ConsistentCopyVisibility on a private-ctor data class.
- Delete dead ReceiveDialog.onGenerate param (never invoked).
- Fix a platform-Boolean type-mismatch on a ThreadLocal read.
Deprecations with no available successor are narrowly @Suppress-ed with
a reason: androidx.security.crypto (EncryptedSharedPreferences/MasterKey),
androidx.privacysandbox.ui, WebView.databaseEnabled, BluetoothDevice
.connectGatt, media3 setEnableAudioTrackPlaybackParams, FirebaseMessaging
.token, and InputMethodManager.SHOW_IMPLICIT.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Xb9YbBqhdZsHxMzitmyvn
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:
brainstorm: favicon.svg AND favicon.png <- the PNG is why it works
ditto: logo.svg only (+ an apple-touch-icon nobody read)
Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.
Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.
Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.
No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.
Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.
The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.
Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.
Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.
So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.
Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.
The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.
Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.
Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Activity.dispatchKeyEvent is a public framework hook; lint flags the
override only because androidx.core's intermediate override carries a
library-group @RestrictTo. Scoped to the method so the check stays live
for genuine restricted-API use. Makes :nappletHost:lintDebug pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLfwhTdf72qFPnmPRYnzqu
- align voice-file debug log with deleteOrWarn's is-gone contract
- Convert the delete-then-warn sites the sweep left hand-rolled in already
touched files: ThumbnailDiskCache corrupt-file and temp-thumbnail cleanup,
NappletBlobCache.put leftover temp, and SecureKeyStorage's bare delete of
the fallback key file (the highest-stakes delete in that file).
- Drop the exists() guards left layered over deleteOrWarn — the helper
already treats an absent file as silent success.
- Collapse AccountManager's legacy-file triple into a loop and drop the
stale "silent" from its comment.
- Snapshot lastModified alongside length in NappletBlobCache.trimToSize so
sortedBy compares in-memory values instead of stat-ing per comparison.
- Promote DesktopTorManager's private restrictToOwner into a shared
File.restrictToOwner(tag) in commons (600 files / 700 dirs) — the repo's
sixth private copy of this pattern was one too many; the remaining copies
can migrate incrementally
Add a direct link to each running app's editable Connected Apps
permission-detail screen from its top pull-down sheet, so users can
change the trust level and per-capability grants as they navigate.
Covers every top pull-down rendering:
- Embedded napplet/nsite and web-app tabs (Compose TopControlSheet),
keyed by the napplet `pubkey:dtag` coordinate or the web client's
`browser:<origin>`.
- Full-screen sandbox host and direct-browser activities (native
NappletControlSheet), via a new MSG_OPEN_PERMISSIONS IPC: the host
(which can't state its own coordinate) sends its launch token or
visited origin, and the main-process broker resolves the trusted
coordinate and opens MainActivity through a `connectedapp?coordinate=`
deep link added to uriToRoute.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dXhUL8To3qVXJVBZGCmhU
The full-screen direct-WebView browser (NappletBrowserActivity, launched when
you type an address) and the sandbox host (NappletHostActivity) used an older
console design that diverged from the embedded tabs' Compose chrome:
- The Console row in NappletControlSheet was a plain action row and the bottom
pull-up grabber was always visible. Make Console a Switch toggle (like the Tor
row / the Compose TopControlSheet), and hide the whole NappletConsolePanel
until the toggle is on — turning it on reveals the sheet already pulled up,
mirroring BottomConsoleSheet.
- The console grabber/panel sat at elevation 0 while the top sheet's panel is at
6dp, so an open top sheet drew over the console when they overlapped (e.g. in
landscape). Elevate the console sheet above the top sheet so its pull tab and
log render on top, matching the Compose layer where BottomConsoleSheet is
composed after TopControlSheet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9kfpNrBRB8WJNi67NHGGX
setAlgorithmicDarkeningAllowed(true) was added to force-darken pages that don't
implement prefers-color-scheme. Combined with nightThemedContext (which forces the
embed WebView's isLightTheme=false), it now also runs on pages that are ALREADY
dark but don't declare CSS color-scheme support — e.g. ditto.pub, which ships
<html class="dark"> by default — and algorithmically inverts their nav bars to
light, leaving "dark content, light bars".
prefers-color-scheme: dark is driven by isLightTheme (nightThemedContext)
INDEPENDENTLY of algorithmic darkening — device-verified: embedded pages still
report prefersDark=true with darkening off — so dropping it keeps real dark-aware
sites dark while no longer corrupting dark-by-default ones. The trade-off (a site
with no dark mode of its own renders light instead of being force-inverted)
matches how a real mobile browser behaves.
Removed from all four embed/host WebView configs (browser + nsite/napplet,
embedded + full-screen) along with the now-unused WebSettingsCompat import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The napplet/nsite host (NappletHostActivity) removed its loading splash the
moment the index probe succeeded and then mounted a WebView with no progress
tracking at all, so during the seconds the shell + bundle take to load (notably
over Tor) the user saw only the WebView's dark colorBackground — a black screen
with no sign anything was happening, especially in dark theme.
- Add a thin browser-style determinate progress bar pinned to the top edge,
driven by WebChromeClient.onProgressChanged and hidden at 100%, to both the
napplet/nsite host and the URL browser (NappletBrowserActivity).
- Mount the WebView under the loading splash and keep the splash (now opaque)
until first paint (onPageCommitVisible) instead of removing it on mount, so
there is never a blank/dark gap between probe-success and the shell's first
frame. This mirrors the pattern the URL browser already used.
- Add a developer console (NappletConsolePanel) to the napplet/nsite host,
wired through the existing onConsole hook in NappletControlSheet, and forward
the page's console.* output to it.
- Surface failed resource fetches (onReceivedError / onReceivedHttpError) as
ERROR lines in the console on both hosts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D4iYA4Qf5guWZyKexkcmhb
Tapping the "loads over Tor" row on an nSite's pull-down sheet popped a
confirm dialog explaining the routing change. Users already know what Tor
is, so toggle the routing directly on tap (still relaunches the session to
rebuild the proxy + content server) and remove the now-unused strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177rhf2L93YRcq6NrWkkM4Q
The embedded in-app browser and napplet/nSite surfaces rendered web content in
the device theme, ignoring the app's DARK/LIGHT preference — a site with dark
support stayed light when the app was dark (and vice versa). The full-screen
activities had the same latent gap (they followed the device, not the app).
Root cause: WebView's dark decision (prefers-color-scheme via algorithmic
darkening) reads the context's THEME (?android:attr/isLightTheme), not just the
Configuration uiMode. The off-window SurfaceControlViewHost surface context
carries neither the host window's theme nor its night mode, so the renderer came
up light. The old applyNightMode() used UiModeManager.setNightMode — a
permission-gated no-op — so the theme never reached the WebView at all.
Fix: build every embed/host WebView from nightThemedContext() — a
ContextThemeWrapper over a forced-night/day Configuration with a DayNight theme,
so the theme's isLightTheme resolves from the app's resolved theme. Shared in
EmbedWebViewTheme.kt; used by NappletBrowserService, NappletHostService,
NappletBrowserActivity, and NappletHostActivity. Removed the dead applyNightMode
no-op from all four. (Verified on device with a throwaway SurfaceControlViewHost
repro: config-only context does NOT work; setForceDark is a no-op at targetSdk
37; setApplicationNightMode does nothing; the DayNight ContextThemeWrapper is
what flips the renderer, even across the cross-process embedded surface.)
Device-verified: all four surfaces follow the app theme even when it differs
from the device.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Opening an embedded page full-screen and returning left every embedded WebView
in the :napplet process broken: dead DNS (ERR_NAME_NOT_RESOLVED), DOM reads
returning empty (a field that visibly shows text reports value==""), dead
text-selection highlight, and broken IME (caret stuck at 0, can't delete).
Two process-global defects in the full-screen hosts were corrupting the shared
WebView state the embedded surfaces rely on:
- pauseTimers()/resumeTimers() are PROCESS-GLOBAL — they pause/resume JS,
layout and parsing timers for every WebView in the process. The full-screen
activities (and the napplet embed pause/resume path) called them on their own
lifecycle, so returning from full-screen froze the embedded surfaces, which
have no resume of their own. Replaced with per-WebView onPause()/onResume()
(which pause only that surface's JS/DOM — still satisfies the napplet
background-security goal). No process-global timer calls remain.
- WebView.destroy() was called while the WebView was still attached to the
window, which corrupts the shared multiprocess renderer. Detach (removeView)
and stopLoading() before destroy() in both full-screen activities.
This also resolves the long-standing "selection highlight dead after a
full-screen excursion" blocker — same root cause.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds out host-drawn text selection for embedded napplet/nsite/browser
surfaces toward native parity, and fixes the bugs found while exercising it.
- Magnifier loupe (#4): EmbeddedMagnifier + provider-side pixel capture
(EmbeddedMagnifierProbe) shipped over IPC for both embed paths; the
caret/selection handles drive it via OnMagnify.
- SelectionUiState: single source of truth for the overlay show/hide rules
(insertion caret / in-field range / page-text range + dragging/scrolling).
- EmbeddedSelectionDrag: suspends the nav drawer's edge swipe while a handle
is dragged (auto-scroll #9).
Bug fixes:
- No more overlay blink on word-select: the shim's selection-reveal scrolls
(a textarea auto-scrolling to show a forming/re-asserted range) no longer
trip the hide-on-scroll path, and the hide self-heals instead of being
re-armed indefinitely.
- RemoteImeView debounces the range-lost signal so a transient collapse that
gets re-asserted doesn't flicker the handles/toolbar.
- Focusing a field clears any page-text selection (shim + host), so the stale
page handles/Copy bar no longer linger above — and stop stealing drags from —
the field overlays; also cancels any in-flight scroll-hide on focus.
- Caret insertion-handle drag now actually moves the caret: read the pointer
delta with positionChangeIgnoreConsumed() before consuming, so the value
isn't zeroed by our own consume (or the sandbox surface consuming the move).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
AGP 9.2.1 treats resources defined in both an app module and a library module
with the same key (qualifiers="") as an error in non-debug builds.
`browser_address_hint`, `browser_console_title_short`, `browser_console_title`,
`browser_console_clear`, and `napplet_untitled` were defined in both
`:amethyst/values/strings.xml` and `:nappletHost/values/strings.xml`.
Both `:amethyst` and `:nappletHost` depend on `:commons`, so the canonical
home for these shared strings is `commons/src/androidMain/res/values/strings.xml`.
Update callers in both modules to use `com.vitorpamplona.amethyst.commons.R as
CommonsR`. Locale translations in amethyst's `values-*/` directories remain as
Android resource overlays (app module overrides library module at merge time).
Fixes: Found item String/browser_console_clear more than one time (packageFdroidBenchmarkResources)
The "locked nApplet vs open nSite" distinction was a `websiteMode: Boolean`
threaded through an Intent extra and re-branched at eight independent sites
across the launcher, the content server, both host surfaces, and the chrome.
That is one coupled security posture (capabilities, CSP, NIP-07 injection,
off-origin policy, network UI) expressed as scattered, drift-prone flags — and
boolean-blind, since the "website" is actually the *more* capable mode.
Introduce `HostProfile` (NAPPLET | WEBSITE), resolved once in the trusted main
process and carried over the Intent/Messenger boundary as its name. Every
coupled consequence now reads from one place:
- `declaredCapabilities(requires)` — THE broker grant, minted into the token
- `appCsp` / `injectsNip07` / `allowsOffOrigin` / `exposesNetwork`
Pure mechanical mapping (every branch 1:1, no behavior change). The wire extra
`EXTRA_WEBSITE_MODE` boolean becomes `EXTRA_HOST_PROFILE` string — safe, as it
is in-app process-to-process IPC, never persisted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrxLCMcQADUPnm8Sj9ZJ63
Resolved conflicts from main adding isFavorite to NappletBrowserActivity.intent()
and FavoriteAppLauncher.launchUrl() while our branch added the theme parameter.
Both params are now present together.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
Adds WebSettingsCompat.setAlgorithmicDarkeningAllowed(true) to all four
WebView setup functions (NappletHostActivity, NappletHostService,
NappletBrowserActivity, NappletBrowserService) so web pages that do not
implement prefers-color-scheme are algorithmically darkened when the
process is in night mode, matching the user's chosen theme.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
Instead of passing the raw ThemeType name ("SYSTEM") to the :napplet process,
resolve it to the actual dark/light value in the main process by reading
context.resources.configuration.uiMode before the launch. The napplet process
now always receives "DARK" or "LIGHT" and sets UiModeManager.nightMode
unconditionally, making prefers-color-scheme reliable on all ThemeType choices.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
The :napplet sandboxed process does not share memory with the main process,
so UiModeManager night mode set in the main app does not propagate there.
Pass the user's ThemeType name ("DARK"/"LIGHT"/"SYSTEM") via Intent extras
and Messenger IPC bundle keys, then apply UiModeManager.nightMode in each
receiving surface (NappletHostActivity, NappletBrowserActivity,
NappletHostService, NappletBrowserService) before any views or WebViews are
created. This makes prefers-color-scheme and the activity chrome match the
user's chosen theme in all napplet/nsite/web-app surfaces.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
Remove the star icon from the browser URL bar (OmniBar) and add a
favorite toggle row to both pull-down sheet surfaces instead:
- Compose TopControlSheet (embedded web tabs and napplets): shows
filled/outline star with "Add to favorites" / "Remove from favorites"
sourced from FavoriteAppsRegistry; isFavorite state flows reactively
through EmbeddedTabChrome so the label updates without reopening.
- Native NappletControlSheet (full-screen NappletBrowserActivity): same
toggle backed by a new MSG_TOGGLE_WEB_FAVORITE IPC message handled in
NappletBrokerService; initial state is passed via intent so the star
opens in the correct filled/outline state for the launch URL.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SBpBE7bQJ2JRni6sYG8UDo
Captures JavaScript console output (log/warn/error/debug) from embedded
WebViews and surfaces it via a bottom pull-up sheet, triggered by a new
"Console (N)" row in the existing top pull-down control sheets.
Embedded browser (Compose):
- NappletBrowserService: attaches WebChromeClient to intercept console
messages and forwards them to the client process via new MSG_CONSOLE_LOG
IPC message in NappletBrowserContract
- EmbeddedBrowserController: implements new ConsoleBridge interface,
stores up to 200 entries in a SnapshotStateList observable by Compose,
handles the incoming IPC message
- TopControlSheet: adds optional consoleCount/onConsole params; shows a
"Console (N)" row when onConsole is provided
- BottomConsoleSheet: new Compose pull-up panel anchored at the bottom,
with level-coloured monospace log entries and a Clear button
- EmbeddedTabLayer: wires ConsoleBridge → TopControlSheet → BottomConsoleSheet
Full-screen activity browser (native Views):
- NappletBrowserActivity: attaches WebChromeClient, wires NappletConsolePanel
and updates the control sheet count label on each new entry
- NappletControlSheet: adds optional onConsole callback and updateConsoleCount()
- NappletConsolePanel: new native-View bottom pull-up panel with scrollable
log entries, grab-to-open gesture, and Clear button; capped at 200 entries
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QXpdfwj2cujnEa7HPzQXj
Mirrors the web-app load-recovery fix to the napplet/nsite path and fixes a
layer bug that kept the overlay from ever showing.
The embedded surface (SandboxedSdkView) is drawn by EmbeddedTabLayer, which
sits *above* the nav screens in the shell. So a loading/error overlay placed in
the favorite screen was covered by the surface's opaque pre-first-frame
background — the black void persisted. Move the overlay into EmbeddedTabLayer,
drawn over the active tab's bounds (where the chrome sheet already lives), so it
actually covers the surface. Also fixes the overlay sizing (fillMaxSize, not
matchParentSize, which collapsed to zero inside the reserved Box).
- Promote load state to the EmbeddedSurfaceController interface (loadStatus /
onLoadStatusChanged / retry), so EmbeddedTabLayer renders one overlay for both
the browser and napplet controllers. Shared EmbeddedLoadStatus +
EmbeddedLoadOverlay.
- NappletHostService now reports main-frame load state (start/finish/error) over
a new MSG_LOAD_STATE; EmbeddedNappletController relays it and exposes retry()
(= reload the verified content).
- The web-app path keeps its about:blank → canonical-URL self-heal; the napplet
path has no client-supplied URL to drop, so retry = reload.
Verified on device: with the network cut, the brainstorm tab shows
"Couldn't load this app." + Retry; restoring the network and tapping Retry loads
the page.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A favorite web app pinned to the bottom bar at runtime could come up on a
blank surface (black, then white after a manual reload) and never recover.
Its warm browser session settled on about:blank — its real URL was dropped on
the way in — and the chrome Reload button calls WebView.reload(), which just
re-loads about:blank instead of the favorite's page.
Fixes:
- The provider now reports main-frame load state (start/finish/error) over a
new MSG_LOAD_STATE. When a favorite session settles on about:blank while it
has a real URL, the controller re-navigates to the canonical URL once.
Gated on a real startUrl, so the generic browser's intentional about:blank
new-tab page is left alone. Adds controller.retry() (navigate-to-canonical,
not reload) for the chrome retry path.
- FavoriteWebAppScreen now draws a loading spinner until a real page paints,
and an error + Retry overlay when the main frame fails or the load stalls
(12s) — so a slow, blank, or failed load is no longer a silent black/white
void.
Scoped to the browser/WebUrl path; the napplet/nsite path is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the omnibox work to modernize the launcher list now that favorites
and visit history both exist:
- Idle browser home (BrowserHome): pinned favorites on top under a "Favorites"
header, then a "Recent" section from the visit history — all in one grid so
they scroll together. Long-press a recent to drop it.
- Typed suggestions are grouped: a highlighted "Favorites" group first (subtle
primary-container tint + medium weight), then "Recent". Favorites still rank
first via the existing frecency boost.
- Real favicons: captured from the WebView that already loaded the page in the
keyless :napplet browser host (so they ride the page's own Tor-routed network
path — the main app never fetches host/favicon.ico itself), scaled and
relayed as PNG bytes over a new MSG_RECORD_ICON IPC, and stored per-host by
BrowserIconRegistry (main process, filesDir). Favorite cards, suggestion
rows, and recent rows all show them, falling back to a glyph.
FavoriteAppIcon gains an optional iconModel; FavoriteAppCell is reusable via a
new LazyGridScope.favoriteAppItems extension so the browser home and the
Favorite Apps tab share one cell. Thumbnails deferred to a follow-up.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
Refines the browser URL-bar experience across the launcher and the in-page
browser chrome:
- Shared URL normalization (commons OmniboxInput): dedupes the logic that was
copied between BrowserScreen and NappletBrowserService, recognizes bare
domains/localhost/IPs, falls back to a (configurable) DuckDuckGo search, and
flags .onion as Tor-only so the launcher forces Tor for it.
- Omnibox suggestions (commons OmniboxSuggestions): ranks favorites + visit
history by prefix/substring match, favorite boost, and frecency; deduped by
host. The launcher body turns into a suggestion list as you type.
- Inline ghost-text completion in the address field (TextFieldValue selection),
completing a typed host fragment to the top-ranked host.
- Visit history (BrowserHistoryRegistry, main process): a device-local,
bounded, DataStore-backed store. Pages are recorded ONLY on a clean
main-frame load — relayed from the keyless :napplet browser host over a new
MSG_RECORD_HISTORY IPC — so misspelled/unresolved addresses never enter it.
- In-page editable address bar (websites only) in NappletControlSheet, showing
the live URL + a security glyph (Tor/https/plain) and loading what the user
types. nsite/napplet hosts pass no navigate callback, so they never get one.
Pure logic is covered by unit tests in commons.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
The embedded surfaces' Compose TopControlSheet and the full-screen activities'
native NappletControlSheet are deliberate twins in different processes/modules
(Compose in the main app vs hand-built Views in the Compose-free :napplet
sandbox host), so they can't share a composable — but they should render
identically. Bring them in line:
- Uniform row rhythm: every action/Tor row now uses the same 10dp vertical
padding in both. The Compose switch row was 6dp while items were 12dp; both
are now 10dp, matching the native rows.
- Native Tor row now uses a real framework Switch as the state indicator (like
the embedded row) instead of an icon whose tint/alpha encoded on/off; the icon
is a steady muted tint and the whole row is the toggle target.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Sandbox host services (per-session correctness + leaks):
- NappletHostService/NappletBrowserService: guard broker-reply delivery against
a stale tab (drop replies whose session was replaced) and wrap postMessage in
runCatching so a torn-down WebView can't crash the relay; tear down each tab's
content server + WebView on session close and in onDestroy; refuse to build an
orphan WebView for an unknown session.
- NappletContentServer.close(): shut the OkHttp dispatcher + evict the pool off
the hot path so a closed tab doesn't leak connections/threads.
- NappletBlobHttp: bound a blob fetch end-to-end with a callTimeout so a stalled
Tor exit can't pin the WebView worker thread indefinitely.
- UiAdapter close(): hop to the main thread before destroying the WebView.
Embedded IME (shim.js + RemoteImeView):
- Surrogate-pair-safe diff so an edited astral char (emoji, CJK-supplement) is
never split into a lone surrogate in the synthesized InputEvent data.
- Real contenteditable support: map char offsets through Ranges and replace in
place instead of overwriting textContent (which destroyed structure + caret).
- Dedup selectionchange against the last applied selection so our own setSel
doesn't echo back to the host as a fresh edit.
- RemoteImeView flushes synchronously at the outermost batch close, preserving
the composing region across a compose+commit in the same frame.
Embedded layer + preloader:
- Resize the cross-process surface to the snapped imeAnimationTarget instead of
the animated ime inset, so it doesn't reconfigure every keyboard-slide frame.
- yield() between favorites in the startup sweep so building WebViews doesn't
monopolize the frame.
- Per-site Tor/open-web registries expose awaitReady(); the preloader awaits
hydration before its first routing decision so a cold start can't route a
site the user pinned to the open web through Tor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Mirror the browser host's per-session refactor for the napplet/nsite embed
provider. A single NappletHostService instance is shared by every embedded tab
(same bound Intent), but it kept one set of fields (client messenger, config,
content server, WebView, broker bridge), so with >1 napplet tab open the
controls (reload/back/pause/resume), navigation state, "allow always" notices,
NIP-07 traffic, and IME all routed to whichever tab was created last.
Collect all per-surface state into a NappletTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):
- Controls/pause/resume resolve the target tab by id and act on its own WebView.
- Content server, shell handshake (declaredDomains), launch token, and page
state/notices are per tab; onShellMessage resolves the tab by its WebView.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
relay pushes come back tagged to the right tab — no id rewriting, per-tab
origin/fire-seq state.
- onSessionClosed drops the tab and destroys only its own WebView; the broker is
bound once for the whole service.
EmbeddedNappletController generates a unique id and stamps it on all messages.
Both embed hosts (browser + napplet) are now fully per-tab correct, including the
keyboard for multiple simultaneous tabs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Extend the embedded keyboard to napplet/nsite surfaces. The shim's IME agent now
installs on any embedded surface (gated by __nappletImeProxy), reaching native
over whichever transport it has — the direct bridge for the browser, the trusted
shell relay for napplets — both through send().
- NappletContentServer gains an imeProxy flag that injects __nappletImeProxy
before the shim; NappletHostService sets it (embedded), the full-screen
NappletHostActivity leaves it off (native keyboard).
- NappletHostService relays ime.* between the applet (via the shell bridge) and
the client (MSG_IME_EVENT / MSG_IME_OP) — the shell already forwards all
message types, so no change to the trusted shell page.
- EmbeddedNappletController implements EmbeddedImeBridge, so EmbeddedTabLayer's
RemoteImeView drives it exactly like the browser.
Correct for a single nsite/napplet tab. Multiple simultaneous napplet tabs share
the host service's single client/bridge pointer (same limitation as reload/back/
NIP-07 there) — making that per-tab needs the session-scoping the browser host
already got; tracked as a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded browser renders cross-process through SurfaceControlViewHost, which
forwards touch but not the soft keyboard (the embedded window can't be an IME
target, and androidx.privacysandbox.ui never wires IME). So focusing a field in
an embedded page did nothing.
Bridge the keyboard instead: host it in the main app window and relay editing to
the page.
- Shim IME agent (embedded browser only, gated by __nappletImeProxy): tracks the
focused editable, reports focus/blur/external-change, and applies host ops
(commit / compose / delete / key / editor-action) with real input & composition
events. Scrolls the field into view on focus.
- NappletBrowserService relays ime.* envelopes between the page bridge and the
client (MSG_IME_EVENT / MSG_IME_OP), per tab.
- EmbeddedBrowserController implements EmbeddedImeBridge (parses events, sends ops).
- RemoteImeView: an invisible EditText in the main window that takes the keyboard
for the active tab. Keeps a real local Editable (so the platform handles
composing/suggestions/selection) while an InputConnection wrapper forwards every
op to the page. Maps web input types / enterKeyHint to inputType/IME action.
- EmbeddedTabLayer hosts the proxy bound to the active tab and shrinks the active
surface by the IME height so the page can scroll the field clear of the keyboard.
Covers <input>/<textarea> fully and contenteditable best-effort (plain text).
Napplet/nsite embeds still need their own wiring (the shell path); this is the
browser surface.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A single NappletBrowserService instance is shared by every embedded browser tab
(they bind the same Intent), but it kept one set of fields (webView, client
messenger, NIP-07 bridge state, reply messenger), so with >1 browser tab open:
controls (reload/Tor/back/navigate) hit whichever tab opened last, URL updates
went to the wrong address bar, and NIP-07 responses/pushes could land in the
wrong page.
Collect all per-surface state into a BrowserTab keyed by a client-stamped
session id (KEY_SESSION_ID on MSG_CREATE_SESSION and every control message):
- Controls resolve the target tab by session id and act on its own WebView.
- pushUrl delivers to that tab's own client messenger.
- Each tab gets its OWN reply Messenger, so broker responses AND unsolicited
relay pushes come back already tagged to the right tab (the broker just echoes
replyTo) — no id rewriting, and per-tab originTokens/mint state.
- onSessionClosed drops the tab and destroys only its own WebView.
EmbeddedBrowserController generates a unique session id and stamps it on all
messages. Tor note: the WebView proxy override is process-global (Android has no
per-WebView proxy), so toggling Tor still affects every tab; only the toggled
tab is reloaded. The napplet host service has the analogous shared-instance
shape (black-out already fixed) but isn't session-scoped yet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A single NappletBrowserService / NappletHostService instance is shared by every
embedded tab (they bind the same Intent), but each tab opens its own session
with its own WebView; the service's `webView` field is only a "latest" pointer.
The audit-batch "destroy stale WebView before rebuild" in createXWebView was
therefore destroying a *sibling* tab's live WebView whenever another browser/
napplet tab opened a session — exactly the repro: open A, switch to B (B's
create destroys A's WebView), back to A → black, B (created last) stays fine.
- Drop the destroy-on-create entirely; each session's WebView lives until that
session closes.
- onSessionClosed now destroys the closing session's OWN WebView (passed in)
and clears the shared pointer only if it still referenced it, so evicting one
tab can't tear down another either.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
A vertical LinearLayout defaults its children to MATCH_PARENT width, so the
collapsed grabber chip's rounded background spanned the entire screen. Give the
grabber explicit WRAP_CONTENT layout params (centered) so only the chip shows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen browser/napplet-host activities still showed the old corner
pill/globe — which sits exactly where a site puts its own login avatar. Add a
native-View NappletControlSheet (the twin of the embedded tabs' Compose
TopControlSheet): a small grabber at the top edge that pulls down to the page's
controls, and wire both :napplet full-screen activities to it.
- NappletControlSheet: title row (shield/globe), optional Tor row, reload, and
optional "what it can access". Tor supports an inline toggle (browser) or a
tap-through to a confirm dialog (nSite host, where switching rebuilds the
session). Tap or vertical drag to expand/collapse.
- NappletBrowserActivity / NappletHostActivity: drop buildFloatingChip/chipGlyph
for buildControlSheet(); attach at Gravity.TOP, full width.
- Add short napplet_net_tor_label / napplet_net_open_label strings.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Durability & correctness:
- FavoriteAppsRegistry: tombstone removals made before async hydration so a
just-deleted favorite can't be resurrected by the disk merge.
- BottomBarEntry: stable @SerialName discriminators so persisted bottom-bar
configs survive class renames/moves.
- EmbeddedTabHost: guard reportBounds/setActiveChrome by active id so a
cross-fading outgoing screen can't clobber the incoming tab's bounds/chrome.
- EmbeddedNappletController: replay a parked-before-bound pause after session
create so a never-shown applet doesn't come up running.
- NappletBrowserService: bind the broker once (no leaked binding on re-create),
destroy a stale WebView before rebuilding, and reload only after the async
proxy override actually applies. NappletHostService: same WebView-reuse guard.
- NappletBrokerService: cap concurrent foreground leases so a misbehaving
sandbox can't pin Tor/relays with unbounded arbitrary keys.
Perf:
- Screens publish a remembered EmbeddedTabChrome; host short-circuits identical
publishes so the tab layer isn't recomposed every frame.
- AppBottomBar resolves favorites via an id-indexed map, not a per-entry scan.
- TopControlSheet keyed on the active tab so its expand state resets per tab.
Cleanup:
- Delete dead BrowserHostActivity + EmbeddedBrowserSurface + AppControlPuck and
their manifest entry; drop the unused `ready` surface state; null controller
refs on unbind; refresh stale z-order/host docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
- Remove the shipped scroll/zoom diagnostics from NappletBrowserService (per-touch
MotionEvent log and per-page zoom log).
- NappletBrowserActivity now renews its foreground lease on a 30s heartbeat like
NappletHostActivity, so the broker's 90s watchdog can't reap it (tearing down
Tor/relays) while the browser is genuinely foreground.
- Persist the per-host Tor choice against the host actually displayed (webView.url),
not the start URL, so an in-page navigation doesn't save the choice to the wrong site.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded surface forwarding taps but cancelling drags was a known bug in
androidx.privacysandbox.ui alpha10: with the provider surface z-ordered above,
"the gesture is exclusively received by the provider window and not transferred
to the client window" (alpha13 release notes). alpha15 then "set the default
Z-ordering to below" and "added support for the UI provider to receive
MotionEvents in this mode after being received by the client window" — i.e. the
drag-input path we needed.
Bump alpha10 → alpha17 and adapt the changed API: openSession takes SessionData
instead of a windowInputToken IBinder, Session adds notifySessionRendered, and
the session-state listener became setEventListener(SandboxedSdkViewEventListener)
(ready now flips on onUiDisplayed). The direct-WebView browser is unaffected
(it doesn't use this library).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Confirmed on a real device: the streamed SurfaceControlViewHost surface forwards
taps but drops scroll/zoom/keyboard gestures — a hard limitation of
androidx.privacysandbox.ui on current Android. No tweak fixes it.
Add NappletBrowserActivity: a full-screen browser that hosts the WebView
*directly* in its own window in the keyless :napplet process, so scrolling,
pinch-zoom, and the soft keyboard (windowSoftInputMode=adjustResize) all work
natively. It carries over NappletBrowserService's per-origin NIP-07 bridge and
Tor proxy, plus NappletHostActivity's trusted chip, loading screen, and
foreground hold — so it stays just as keyless (page JS runs in :napplet, every
window.nostr call is brokered + consent-gated per origin in the main process).
Web favorites and URL launches now open this activity instead of the streamed
BrowserHostActivity. Per-host Tor choice persists via a new MSG_SET_WEB_TOR
broker message (the :napplet process relays it to WebUrlNetworkRegistry).
The embedded bottom-row web tab still uses the streamed surface (it must, to
live inside MainActivity) and so still can't scroll — that's a follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Replace the full-width top bar on every running app surface (embedded web/nsite/
napplet tabs and the full-screen browser + napplet activities) with a small
floating control puck. Apps already title themselves, so instead of repeating
the name we keep one always-visible trusted marker — the sandbox shield for
napplets/nsites (the anti-phishing affordance the page can't draw over), a globe
for the plain browser — that expands on tap to reveal the actions (Tor, reload,
pop-out, access sheet, close).
- New shared AppControlPuck composable backs the three Compose surfaces;
NappletHostActivity gets the native-View equivalent.
- Embedded tabs inset their reserved surface bounds by the puck height
(AppControlPuckReserve) so the warm surface, drawn over those bounds above the
nav tree, doesn't cover the puck. Full-screen activities float it on top.
- EmbeddedTabTopBar is removed (no longer used).
Also remember the Tor on/off choice per web client: some sites' servers reject
Tor exits, so a user opting one out must have it stick. New WebUrlNetworkRegistry
(main process, keyed by host, device-local) mirrors NappletNetworkRegistry; the
browser reads it for the initial route and writes on toggle, in both the embedded
tab and the full-screen browser.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Logs the WebView's current scale, density, and pixel widths on page finish, to
confirm whether the 400% zoom is a density/viewport mismatch from streaming the
WebView through SurfaceControlViewHost. Temporary, alongside the touch/session
diagnostics.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Adds two temporary logs to pin down why the embedded browser doesn't scroll:
- provider side (NappletBrowserService): logs each MotionEvent that reaches the
remote WebView, so we can see if touch crosses the SurfaceControlViewHost
boundary at all (returns false, never consumes).
- client side (EmbeddedBrowserController): logs the SandboxedSdkView session
state transitions (Idle/Loading/Active/Error).
To be reverted once the cause is confirmed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The embedded browser / nsite / napplet WebView runs in the keyless :napplet
process, which has no access to the main app's Compose theme, so before a page
painted it showed the WebView default white — jarring against Amethyst's (often
dark) background.
Pass the theme background color (MaterialTheme.colorScheme.background) across the
process boundary and apply it to the WebView, and paint the SandboxedSdkView
placeholder with it too so there's no white flash before the first frame. The
full-screen NappletHostActivity resolves the color locally from its themed
context. Covers the embedded browser tab, the full-screen browser activity, and
the embedded nsite/napplet tab.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN