Animated GIF/AVIF media in the multi-image gallery rendered with its own
intrinsic aspect ratio via fillMaxWidth(), ignoring the ContentScale.Crop
the grid asks for
Notification small icons are rendered by the system as flat, alpha-only
silhouettes, so the always-on relay-connection service showed the exact
same solid gem as real notifications (DMs, zaps, mentions). The persistent
service therefore kept reading as a fresh notification.
Add an outlined (hollow) gem variant, `amethyst_service`, and point the
foreground service notification at it so it's clearly distinguishable at a
glance while staying on-brand.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141R1Km7YsA2miFoxLkXLQk
The Text() calls inside the logs.forEach loop were 8 lambdas deep
(Box > Column > AnimatedVisibility > Surface > Column > Column > forEach > Row),
which caused java.lang.OutOfMemoryError in the Kotlin bytecode optimizer
(ConstantConditionEliminationMethodTransformer). This broke CI when
PR #3353 merged. Extract the Row content into a top-level ConsoleLogRow
composable to reduce nesting depth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt
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
The deeply-nested Text() call inside DropdownMenu > forEach > DropdownMenuItem
generated bytecode with MAXSTACK=26, which caused java.lang.OutOfMemoryError in
the JVM bytecode frame analyzer (ConstantConditionEliminationMethodTransformer).
Extracting DropdownMenuItem into a top-level private composable breaks the lambda
nesting depth and lets the compiler succeed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmiRNcMEync2rVVbrPzWt
TRIM_MEMORY_UI_HIDDEN fires on every app switch, not only under memory
pressure. Clearing Robohash (CPU-intensive SVG assembly) and
CachedRichTextParser to zero there forces a full rebuild on every
resume, causing visible jank.
Restructure the trim tiers to match Android's intent:
UI_HIDDEN (20) — just backgrounded, no pressure:
trim Coil image cache to 1/2 only; leave parsed-text and
avatar caches warm so resume is instant.
BACKGROUND (40) — mild background pressure:
trim images to 1/4, richtext→100, robohash→20, nip11→200.
MODERATE (60) — system is hurting:
clear images, richtext→50, robohash→10, nip11→100.
COMPLETE (80) — kill imminent:
clear everything including nip11.
Foreground levels (RUNNING_LOW/RUNNING_CRITICAL) are unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
Clearing lastNotes caused the next additive update to call
refreshSuspended(), reloading the full filter limit (~500 notes) from
LocalCache and immediately undoing the trim.
With lastNotes intact the fast additive path stays active: only
genuinely new notifications are appended, so the card list remains near
maxItems until the next full feed key change or navigation event.
The Note refs in lastNotes are the same object instances already held
by LocalCache. They are freed when MemoryTrimmingService prunes
LocalCache and the following refreshSuspended() replaces lastNotes with
the pruned set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
CardFeedContentState.lastNotes holds strong references to every raw Note
that passed the notification filter, used for additive dedup
(filteredNewList.minus(lastNotesCopy)). Trimming only the Card list left
all those Note objects pinned — they couldn't be GC'd even if LocalCache's
SoftCache had evicted them.
Fix: clear lastNotes/lastAccount alongside the Card list truncation.
The next additive update finds lastNotes == null, skips the fast path,
and calls refreshSuspended() — one controlled rebuild from LocalCache
that also resets the dedup set to the current state.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
- Show source filename and line number as dimmed secondary text on each
log entry (matches native NappletConsolePanel behaviour)
- Replace hardcoded #FF9800 orange with a dark-mode-aware amber pair:
#E65100 in light mode, #FFB74D in dark mode
- Cap panel height at 40% of screen height (was fixed 240dp) so large
phones display more log lines
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QXpdfwj2cujnEa7HPzQXj
At UI_HIDDEN (fires on every app switch), nip11Cache.trimToSize(0)
was clearing all successfully-fetched NIP-11 relay documents, forcing
10–50 redundant HTTP fetches on the next foreground resume.
- Change UI_HIDDEN trim target from 0→100; the 100 most-recently-used
relay docs are kept across a background/foreground cycle.
- Stop trimming relayInformationEmptyCache in Nip11CachedRetriever:
it holds only lightweight display-name+favicon-url placeholder objects
(no network data), so trimming saves negligible memory but silently
re-triggers NIP-11 HTTP fetches for every relay on resume.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
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
Add trimToSize(maxItems) to:
- CachedRichTextParser: trims richTextCache (500) and isMarkdownCache
(200) proportionally
- CachedRobohash: trims the ImageVector LruCache (100)
- Nip11CachedRetriever: trims both the document and empty-placeholder
caches (1000 each)
Wire all three into AppModules.trim() tiered by OS pressure level:
RUNNING_LOW → 50% capacity
RUNNING_CRITICAL → 20% capacity
UI_HIDDEN+ → evict all (app not visible, safe to clear)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
- ExoPlayerPool.releaseWarmPool(): evicts all paused-with-buffer (warm)
players back to the cold pool. Safe under pressure because warm players
are idle; active (checked-out) players are never in either pool.
- PlaybackService.onTrimMemory(): when level >= RUNNING_CRITICAL, drains
both pool instances' warm slots via releaseWarmPool(). The Service
receives onTrimMemory() directly from Android so no routing through
AppModules is needed.
- AppModules.trim(): trims Coil's in-memory image cache proportional to
OS pressure level:
RUNNING_LOW → trimToSize(maxSize / 2)
RUNNING_CRITICAL → trimToSize(maxSize / 4)
UI_HIDDEN+ → trimToSize(0) [app backgrounded, safe to clear]
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019h2c44rAwexuUEP3kky2F3
50 was too aggressive — users would lose most of their scroll history on
any critical-pressure event. 200 is a better balance: still releases
~60% of the 500-note strong references per feed while keeping a
reasonable amount of history visible without a reload.
Co-Authored-By: Claude <noreply@anthropic.com>
Feed lists hold ImmutableList<Note> references. At 40+ feeds × 500 notes
each, that's 20k strong Note references that prevent GC from collecting
objects that LocalCache has already pruned. Under RUNNING_CRITICAL the
feeds are shrunk to 50 items, releasing ~19k references per account.
Implementation:
- FeedContentState.trimToSize(maxItems) — truncates the loaded list in-place
- CardFeedContentState.trimToSize(maxItems) — same for notification card feeds
- AccountFeedContentStates.trimFeedsToSize(maxItems) — fans out to all feeds
- AppModules.trimLevelEvents: SharedFlow<Int> — broadcasts the OS level
- AccountFeedContentStates subscribes and calls trimFeedsToSize(50) at
RUNNING_CRITICAL, letting the next scroll/refresh repopulate from cache
Co-Authored-By: Claude <noreply@anthropic.com>
Tier 2 (medium, >= RUNNING_LOW): pruneHiddenEvents + pruneHiddenMessages
— muted/blocked content is known-safe to drop at low pressure.
Tier 3 (critical, >= RUNNING_CRITICAL): pruneOldMessages + pruneRepliesAndReactions
— these are more aggressive since they remove content the user may
still scroll back to, so reserve them for genuine memory emergencies.
Co-Authored-By: Claude <noreply@anthropic.com>
cleanObservers() only removes flows not currently held by the UI, so
it carries no visible side effects and is safe to run on every trim
regardless of pressure level. Move it from Tier 3 (RUNNING_CRITICAL)
to Tier 1 (always) so unused observer links are freed even on mild
memory signals.
Co-Authored-By: Claude <noreply@anthropic.com>
MemoryTrimmingService.doTrim() previously ran the full pruning suite
regardless of how severe the OS signal was. Now it tiers the work by
ComponentCallbacks2 level so low-pressure signals don't pay the cost of
aggressive observer teardown:
Tier 1 (always): cleanMemory + pruneExpiredEvents + prunePastVersionsOfReplaceables
Tier 2 (>= RUNNING_LOW): + pruneOldMessages + pruneRepliesAndReactions
Tier 3 (>= RUNNING_CRITICAL): + cleanObservers + pruneHiddenEvents/Messages
The `level` is now threaded from Amethyst.onTrimMemory → AppModules.trim(level)
→ MemoryTrimmingService.run(…, level) → doTrim(…, level). The existing
scheduled-trim call site (AppModules.trim) defaults to RUNNING_CRITICAL so
its behaviour is unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Shows JVM heap used/max as a color-coded label (green/amber/red) in the
top bar actions on debug builds. Tapping opens a dialog with a full
breakdown: native heap, Coil memory/disk cache sizes, and LocalCache
counts (notes, users, addressables, chatrooms). Polls every 2 seconds
via produceState. Adds MemorySnapshot data class and
collectMemorySnapshot() to DebugUtils for reuse by future tooling.
Co-Authored-By: Claude <noreply@anthropic.com>
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>
- Pinned web favorites in the bottom navigation now show the captured favicon
instead of the generic globe (falling back to the globe until one is
captured). Resolved via BrowserIconRegistry, same as the launcher cards.
- Each Recent row in the browser home gains a 3-dot overflow menu to add the
URL to favorites (or remove it if already favorited) and to remove it from
history. Replaces the prior long-press-to-remove with a discoverable menu;
the row icon shows a star once the site is favorited.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
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
The ifEmpty guard incorrectly reset a user-saved empty bar to defaults.
A successfully parsed empty list (user actively removed all items) is now
preserved. Only blank/unset keys and unrecognizable formats fall back to
DefaultBottomBarEntries.
Co-Authored-By: Claude <noreply@anthropic.com>
Blank stored strings and successfully-parsed empty JSON arrays both
left the bottom bar invisible. Now both cases fall back to
DefaultBottomBarEntries so navigation is never silently lost.
Co-Authored-By: Claude <noreply@anthropic.com>
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
- Disable autocorrect and autocapitalization in the URL bar so domain
names are not mangled by the keyboard's spell-checker
- Align normalizeUrl with NappletBrowserService logic: bare domain
names (no space, contains dot) get https:// prepended; everything
else falls through to a DuckDuckGo search query
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LyxWy2k3AT1LiZSvMsiDx
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