This branch added a lot the existing napplet plans don't describe: embedded warm
bottom-bar tabs on a cross-process SurfaceControlViewHost, an arbitrary-URL
browser host, a soft-keyboard IME proxy, the :nappletHost module split, the
per-session service refactor, the two control-sheet twins, and per-site Tor
routing.
- Add amethyst/plans/2026-06-24-napplet-embedded-tabs.md capturing the final
architecture and file map.
- Note on the 2026-06-19 sandbox-host plan that its rendering model is superseded
(trust model unchanged), pointing at the new doc.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The Browser is reachable both as a bottom-bar tab (top-level, no back arrow)
and from the navigation drawer (pushed onto the back stack). In the latter case
its omnibox now leads with a back arrow that pops, matching the convention the
other launcher/feed top bars already follow (NappletsTopBar et al.): show
ArrowBackIcon when nav.canPop(), nothing otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
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
Per NIP-46 (nostr-protocol/nips#2381), client metadata is the 4th
positional connect param. When it is present but the optional secret or
permissions are not, those slots are now back-filled with empty strings
so the metadata always lands at index 3. Parsing maps empty placeholders
back to null. When no metadata is sent, the array stays as short as
possible for backward compatibility.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
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
Rework the host-side input proxy to mirror Flutter's TextInputPlugin /
InputConnectionAdaptor instead of forwarding individual IME ops:
- RemoteImeView ships the whole editing STATE (text + selection + composing
region) rather than per-op messages, coalesced across IME batch boundaries
(beginBatchEdit/endBatchEdit nesting, like Flutter's batchEditNestDepth). A
TextWatcher + onSelectionChanged flush captures every mutation, so the soft
keyboard, HARDWARE keyboards, autofill, paste, and context-menu edits are all
covered uniformly — they all mutate the same real Editable. Composing region is
read from the platform via BaseInputConnection.getComposingSpan*.
- The shim adopts that state and synthesizes the matching DOM input/composition
events (insertText / insertCompositionText / deleteContentBackward /
insertReplacementText, with compositionstart/update/end) so web frameworks
react as if typed natively — going beyond Flutter, whose consumer is a Dart
widget. A common prefix/suffix diff classifies each change.
- Beyond Flutter: backed by a REAL EditText, so the platform answers
getTextBeforeCursor/getExtractedText, suggestions, and spell-check for free
rather than hand-rolling a ListenableEditingState.
- showSoftInput is posted after focus settles (avoids the show no-op race).
This collapses the op vocabulary to ime.set (state) + ime.action and removes the
commit/compose/delete/key/setSelection messages.
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
Warm every pinned bottom-bar favorite at app startup instead of lazily on first
visit, so a browser favorite (ditto, amy-lm, …) has already downloaded and is
local by the time the user taps it.
- EmbeddedTabFactory: the single place that builds a warm controller, shared by
the favorite screens and the new preloader so they produce the same session
keyed by the same id (whoever runs first wins; the other reuses it).
- EmbeddedTabPreloader (mounted next to EmbeddedTabLayer): sweeps the bottom-bar
favorites and acquires each. Retries for a bounded window so a napplet whose
event hasn't synced, or a Tor proxy still connecting, gets a chance to settle.
- Privacy gate: a Tor-routed site is never preloaded over clearnet while Tor is
merely still connecting — it waits for the proxy port. When Tor is off, or the
user opted the site out, clearnet is the real route, so it preloads at once.
- Seed an approximate viewport (EmbeddedTabHost.seedBoundsIfUnset) so preloaded
surfaces download as a full-size page rather than at the 1dp off-screen
fallback; the first real visit corrects the bounds.
Browser favorites fully preload+download. Napplet/nsite favorites get a warm
session but stay JS-paused until opened (the existing background-gating security
rule for "allow always" apps), so they don't run in the background.
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
Two black-surface reports traced to the active-tab bookkeeping:
- Double-tapping a bottom-bar tab pops and re-adds the SAME route, so the
outgoing screen disposes AFTER the incoming one has already called
setActive(id). clearActiveIfMatches(id) then nulled the active id the new
instance just set (same id "matches"), leaving no active tab — every warm
surface gets shoved off-screen and the embed goes black. setActive now returns
a monotonic ownership token and the disposer clears only if it's still the
latest claim (clearActiveIfOwner), so a re-nav can't null the new owner.
clearActiveChrome got the same twin guard.
- Revert the reportBounds active-id guard added in the audit batch: it tied
contentBounds to the same fragile activeId, so a first-ever embed whose bounds
reported while the id was momentarily unset stayed at Rect.Zero (parked
off-screen → black). Bounds are reported unconditionally again; the two
screens cover the same content area, so there's nothing to clobber.
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
Two regressions from this session's audit batch:
1. Bottom nav bar reset. Adding stable @SerialName discriminators to
BottomBarEntry changed the persisted polymorphic "type" value from the
fully-qualified class name to "builtIn"/"favorite", so configs written by an
earlier build no longer decoded — and the fallback returned an empty list,
blanking the bar. decodeBottomBarItems now migrates the old fully-qualified
discriminators to the short names (recovering the user's customized bar), and
any unrecognizable value falls back to the defaults instead of empty. Locked
with BottomBarEntrySerializationTest.
2. Pull-down sheet interfered with page taps. The expanded top sheet is a
full-width drawer with no way to dismiss except the grabber, so it sat over
the page. Hoist its expanded state into EmbeddedTabLayer (reset per tab) and
draw a full-area dismiss scrim behind the open sheet; collapsed, only the
small grabber is interactive and page taps pass through.
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
Now that the surface is z-ordered below the Compose layer (alpha17), chrome can
finally draw over the page — so replace the slim top bar with a top pull-down
sheet, per request. Collapsed it's just a small grabber centered at the top edge
(out of the top-right corner, where sites put their own avatar/menu); pull it
down or tap to reveal the page's controls: route over Tor, reload, "what it can
access" (sandboxed napplets/nsites), and open full screen.
The active tab publishes its controls as EmbeddedTabChrome; EmbeddedTabLayer
draws the TopControlSheet over the active tab's bounds, after the surfaces so it
sits on top. Applies to both embedded web and napplet/nsite tabs.
The full-screen activities still carry the native corner chip — converting those
to the same pull-down is the next step.
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
Parked (inactive) warm tabs were shrunk to 1dp off-screen, so activating one
resized its surface from 1dp to full size — forcing the SurfaceControlViewHost to
re-render at the new size, which flashed black for ~1s (page appears → black →
reappears) on every tab switch.
Keep parked tabs at the SAME size as the active tab and only shift them
off-screen, so bringing one back is a pure translation: no resize, no re-render,
no black flash. They stay full-size and warm while parked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
orderProviderUiAboveClientUi(false) broke input entirely — with the streamed
surface ordered below the window, the SurfaceControlViewHost stops receiving
touch, so neither the WebView nor the Compose puck got clicks (and the touch
logs went silent). Revert it: the surface must stay z-ordered on top for input.
That makes floating chrome over an embedded surface impossible (the surface is
always above any Compose UI in the window), so move the control puck into a slim
top bar above the surface on all streamed surfaces (embedded web/nsite/napplet
tabs and the full-screen browser). No title — the app titles itself. The native
NappletHostActivity keeps its floating chip (direct WebView, no surface).
Keep the EmbeddedSurfaceTouchHolder scroll fix (claims the gesture from host-side
ancestors). Drop the can't-show Compose loading overlay and the dead chrome-in-
layer machinery.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Diagnosed from on-device logs: touch crosses the boundary but every drag gets
ACTION_CANCEL — a host-side ancestor intercepts the scroll, and the cross-process
WebView can't requestDisallowInterceptTouchEvent itself. Wrap the surface in
EmbeddedSurfaceTouchHolder, which claims the gesture on touch-down so the page
scrolls.
The floating puck couldn't sit over an embedded surface because the surface is
z-ordered on top of the window — so it was either hidden (full-screen browser)
or pushed below an empty reserve band that read as a black bar (embedded tabs).
Order the provider UI below the window UI (orderProviderUiAboveClientUi(false);
input still flows via the SurfaceControlViewHost token) and render the chrome on
top: the active tab publishes its controls as EmbeddedTabChrome and EmbeddedTabLayer
draws the puck over the surface — no reserve band, no black bar.
Add a themed loading placeholder (controller.ready flips on session Active) over
the surface in the tab layer and the full-screen browser, so binding + first
paint shows the app background + a spinner instead of a black box.
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
The cross-process foreground hold relied on the `:napplet` host delivering its
onPause "false" to release. If that process dies while foreground (a crash, an
OS kill) it never sends it, and the broker would hold the main process resumed —
Tor/relays/AUTH up — forever.
Turn the hold into a renewing lease. A resumed host re-reports foreground on a
30s heartbeat; the broker stamps each launch token's last-seen time and a
watchdog reaps any lease older than a 90s TTL, releasing its hold. A live app
keeps renewing so it's never wrongly dropped; a dead one stops renewing and is
reaped within the TTL, bounding any leak to one window instead of forever.
Only the cross-process napplet/nSite path needs this — BrowserHostActivity runs
in the main process, so if it dies the hold and every connection die with it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Opening a full-screen napplet/nSite (`:napplet` process) or browser host
(main process) backgrounds MainActivity, stopping its ManageRelayServices /
ManageWebOkHttp collectors. The underlying WhileSubscribed flows then scale
everything down on their timers — Tor's port (~2s), the relay pool (~30s),
dropping the relay AUTH sessions with it — even though the user is still on a
Nostr surface that brokers NIP-07 + relays back through that very process.
Add a ref-counted SandboxForegroundHold (main process): while held it
subscribes to exactly the flows the resumed UI subscribes to, keeping Tor, the
relay pool, and AUTH up; it releases when the last surface leaves so normal
background scaling resumes. Main-process activities call it directly; the
`:napplet` host can't touch that lifecycle, so it signals foreground over a new
MSG_SET_FOREGROUND IPC and the broker holds on its behalf (token-set keyed,
released on unbind).
Switching between several open surfaces (old.onPause -> new.onResume, plus the
async IPC hop) would dip the count to 0 and back to 1. A short release linger
keeps the same collectors running straight through that dip, so swapping
between open napplets/nSites/browser apps holds the network perfectly steady.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The ic_tor vector painter rendered at its intrinsic size because the Icon had
no size modifier, so it was oversized in the embedded web tab and the
full-screen browser. Pin it to the standard 24dp to match the reload icon.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Two bottom-nav polish items:
- No label on favorite tabs — they now match the built-in items, which are
icon-only.
- Use the app's own icon. FavoriteApp gains an optional iconUrl (the
nsite/napplet manifest icon, captured when you favorite from its card and
persisted). A shared FavoriteAppIcon renders that icon, falling back to a
type glyph (the napplet/nsite mark, or the globe for a plain URL) when
there's no icon or it fails to load. Used in both the bottom bar and the
Favorite Apps grid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Per review, drop the shield-as-Tor (and the security sheet that hosted it):
Tor is now the app's standard ic_tor onion (TorToggleButton, lit when on Tor,
dimmed on the open web), matching how Tor appears on relays and in settings.
The shield is reserved for the nsite/napplet tab, where it genuinely means
"what it can access".
- New shared TorToggleButton (onion).
- EmbeddedTabTopBar takes a `leading` slot: the web tab puts the Tor onion
there, the napplet/nsite tab puts its access shield.
- BrowserHostActivity (full-screen) uses the same onion toggle instead of the
shield/sheet. WebAppSecurityDialog and its strings are removed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The surface adapters set the WebView's layoutParams to a plain
ViewGroup.LayoutParams, but the SurfaceControlViewHost container measures its
children with measureChildWithMargins, which casts to MarginLayoutParams —
crashing the :napplet process with a ClassCastException on first layout.
Use FrameLayout.LayoutParams (a MarginLayoutParams) in both
NappletHostUiAdapter and NappletBrowserUiAdapter (openSession + notifyResized).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen BrowserHostActivity still used the shield (Security icon) as a
bare Tor toggle (pink when on Tor, plain when on the open web) — the same
icon-overload we removed from the embedded tabs. Switch it to the shared
"Security & privacy" pattern: the shield opens the WebAppSecurityDialog with
the Tor toggle inside, matching FavoriteWebAppScreen. The page keeps its
back/close + reload.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The launcher's address field is a plain Row in the Scaffold topBar slot, so
(unlike a Material3 TopAppBar) it didn't apply the status-bar inset and drew
under the status bar. Add statusBarsPadding() to the omnibox row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The web-app tab and the nsite/napplet tab had different bars, and both used
the same MaterialSymbols.Security "shield" for different things — Tor on the
web tab, sandbox access on the napplet tab — which read as the same icon
meaning two things. (Tor's real mark is the ic_tor onion used elsewhere.)
- Add a shared EmbeddedTabTopBar (sandbox shield · title · reload · pop-out)
used by both tabs, so they're visually identical.
- The shield now consistently means "security & privacy": it opens a sheet.
Tor moves into that sheet (a live toggle on the web-app tab; the napplet
sheet keeps its capability list + network line), so the shield is never
confused with a Tor toggle and there's no standalone Tor icon to mistake
for it.
(The full-screen BrowserHostActivity pop-out still uses its own bar; can
harmonize that next if wanted.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
The full-screen NappletHostActivity already consumes system-bar + cutout
insets at its root so its WebView doesn't pad the page for the status/nav
bars a second time (targetSdk 35+ auto-applies received insets to web
content). The embedded surfaces — the browser tab and the new embedded
nsite/napplet tabs — have no such root: the WebView is the surface view, so
it received and re-applied those insets, leaving an empty band under the
status and navigation bars even though the host already places the surface
in the inset-free content area.
Add WebView.dropSystemBarInsets() (zeroes system-bar + display-cutout
insets, keeps IME for keyboard resize) and apply it in both
NappletBrowserService and NappletHostService when the session WebView is
built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
Replaces the parallel bottomBarItems (List<NavBarItem>) + bottomBarFavoriteIds
with a single ordered List<BottomBarEntry>, so built-in destinations and
favorite apps live in one list and can be pinned and drag-reordered together.
- BottomBarEntry = BuiltIn(NavBarItem) | Favorite(favoriteId). The favorite id
already encodes the route's parameters (the url / addressable coordinate), so
each entry maps deterministically to its Route — BuiltIn via NavBarCatalog,
Favorite via Route.FavoriteWebApp/FavoriteNostrApp. (Storing the raw Route
isn't an option: the sealed Route parent isn't @Serializable, so a List<Route>
can't be persisted without annotating the whole ~100-subtype hierarchy.)
- UiSettings/UiSettingsFlow carry bottomBarItems: List<BottomBarEntry>;
UISharedPreferences serializes it as JSON, with a legacy comma-separated
NavBarItem fallback so existing configs still load.
- BottomBarSettingsScreen now shows one reorderable list mixing built-ins and
favorites; the separate favorites section is gone.
- AppBottomBar renders entries in saved order; warm-keep membership derives
from the list's favorite entries.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
isMarkdown() treated the second newline of a blank line as a non-space
character, flipping the line-start tracker off. ATX headings, blockquotes,
and list markers that follow the standard blank-line spacing went
undetected, so NIP-23 long-form articles made of prose plus section
headings rendered as raw text. Exclude newline/carriage-return from the
line-start guard so a blank line stays at line start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>