Compare commits

...
Author SHA1 Message Date
Claude 722d9526a1 fix: log unparseable relay frames at WARN, not ERROR
A frame the client can't decode (non-standard framing, unknown command,
malformed JSON) is a relay-side quirk, not an Amethyst error. For example,
multiplextr.coracle.social replies to a plain REQ with its own multiplexer
envelope `[{"relays":[]},["NOTICE","","Unable to handle message"]]`, whose
first element is an object rather than a command label, so the decoder
throws `Message null is not supported`.

BasicRelayClient already drops such frames and its comment states it
"doesn't expose parsing errors to lib users as errors", yet it logged at
ERROR. Downgrade to WARN so these relay-side quirks stop surfacing as app
errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01THVD3sY6xrxGzBHhUnhJAY
2026-07-07 22:14:39 +00:00
Vitor PamplonaandGitHub 607665e846 Merge pull request #3490 from vitorpamplona/claude/kotlin-compilation-warnings-bcxeyp
Clean up empty when branches and remove unnecessary null checks
2026-07-07 17:46:22 -04:00
Claude 01d61b0851 fix: resolve remaining Kotlin compiler warnings in commons and cli
- Suppress DEPRECATION on REASONABLE_SIGN_KINDS, which intentionally lists the
  deprecated TorrentCommentEvent kind.
- Replace deprecated readLine() with readlnOrNull() in SecureKeyStorage.
- Drop unnecessary !! non-null assertions in KeyCommands and NostrConnect where
  the receiver is already smart-cast to non-null.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:39:04 +00:00
Claude 560b95c2ec fix: resolve Kotlin compiler warnings in quartz and quic
- Replace unused Unit/null expressions in statement-position when branches
  with empty blocks (CommandSerializer, QuicConnection, QuicConnectionParser,
  Http3FrameReader, WtPeerStreamDemux).
- Suppress DEPRECATION on KindNames.names, which intentionally registers the
  deprecated GitReplyEvent and TorrentCommentEvent kinds for display.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018nqdy4VTLKidUWzGTJPja9
2026-07-07 21:32:41 +00:00
Vitor PamplonaandGitHub b5d5fc2d2a Merge pull request #3489 from vitorpamplona/claude/amy-geode-fetch-sync-compare-0xlsl9
fix(quartz): fetchAllPages paging correctness + amy drainAllPages + SeenIds
2026-07-06 20:16:32 -04:00
Claude e8f4c5f806 refactor(cli): align fetch default limit across paths; harden paging
Audit follow-ups before merge:

- amy fetch default limit is now the same on both paths: absent --limit → 100
  for plain AND --paginate (previously --paginate silently meant "unbounded").
  `--limit 0` is the explicit opt-in to drain everything (unbounded); negative
  is rejected. The effective limit is carried on the filter so both paths agree.

- drainAllPages sizes its SeenIds for CLI-scale fetches (initialSlotsPow2 = 12,
  ~64 KB) instead of the large-walk default (~16 MB eagerly allocated per fetch);
  it grows if an unbounded drain needs it.

- fetchAllPages clamps the inclusive advance to `min(pageMinTs, boundary)` so a
  misbehaving relay that answers with an event past the requested `until` can't
  push the cursor upward — the boundary dedup and termination rely on `until`
  never increasing. No-op for honest relays (they only return events ≤ until).

Verified live: default and --paginate both cap at 100; --limit 50 → 50; --limit 0
--paginate drains the full window (>100); paging tests + SeenIds tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:57:40 +00:00
Claude e0ebc8fad6 feat(cli): dedup drainAllPages via SeenIds; unbounded amy fetch --paginate
Two changes to the paginated fetch path:

- Cross-relay dedup before verify. drainAllPages' single consumer now runs a
  SeenIds filter: the same widely-mirrored event arrives once per relay, and the
  repeats are dropped BEFORE the expensive Schnorr verify + store instead of
  after (they were only trimmed by FetchCommand's distinctBy). An id is marked
  seen only once it verifies, so a forged copy (valid id, bad sig) delivered
  first can't suppress the genuine one from another relay. Adds SeenIds.contains
  (peek without recording) for that check-then-add.

- `amy fetch --paginate` no longer forces a --limit. With --limit N it still
  pages up to N per relay; WITHOUT --limit it drains the whole filter unbounded
  (the filter's null limit flows straight through). Plain (non-paginate) fetch
  still trims to the default 100.

Verified live: unbounded --paginate over a ~20-min nos.lol firehose window
returns 406 (all unique, 3s) vs the old 100 cap; --limit 50 caps at 50; default
caps at 100; cross-relay fetch stays count==uniq.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 23:31:09 +00:00
Claude 338c9a41af refactor(quartz): make SeenIds single-writer, move to commonMain
Drop @Synchronized from add/reset/size: SeenIds is now documented as
single-writer (not thread-safe). Callers dedup across concurrent relay
producers by funneling events into one consumer that owns the instance — the
one-consumer ingest pattern used elsewhere — which keeps a single global set,
stays lock-free, and lets resize run without coordination.

With the JVM-only @Synchronized gone the class is pure common Kotlin
(LongArray + Hex.readLong), so it moves from the jvmAndroid source set to
commonMain and is now available on every target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:52:46 +00:00
Claude cb493a2291 feat(quartz): add SeenIds — a memory-lean event-id dedup filter
A run-scoped "already seen this id" filter for large, mostly-duplicate id
streams (a broad relay walk re-receiving the same event from many relays).
Keys on the first 128 bits of the id, sliced straight out of the hex with
Hex.readLong (table lookups, no parse, no allocation), in one open-addressed
LongArray — ~16 bytes/entry and the 64-char String is never retained, so tens
of millions of ids cost ~1 GB instead of a HashSet<String>'s ~6 GB. add() is
O(1) and synchronized.

Lives in the jvmAndroid source set (uses @Synchronized; a 40M-id walk is a
server-side concern). Ports the caller's implementation with the
parseUnsignedLong hot path swapped for Hex.readLong (~45-70 ns/op cheaper).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 22:38:37 +00:00
Claude 5c016fc2d4 fix(quartz): fetchAllPages must not drop events at page boundaries
fetchAllPages advanced with `until = oldest - 1` (exclusive) and no dedup. That
skips any event sharing the boundary second that didn't fit in the page — which
happens at *every* page boundary landing inside a second, not just pathological
dense ones — silently dropping events. An in-process probe with no second denser
than the relay's page cap still lost one event straddling the boundary.

Page inclusively now: `until = oldest created_at of the previous page`, and drop
the re-fetched boundary events by id. The dedup set is bounded to just the current
boundary second (`until` only decreases, so duplicates can only recur there), so
memory stays O(one second), never O(total).

A single second denser than the relay's page cap can't be drained (its tail is
unreachable — no client-side fix; raising the request limit is futile since we
already send one above the relay's cap). Once a page yields nothing new we step
strictly past that second so paging keeps progressing to older events instead of
stalling forever.

Tests: boundary-straddle retrieves all 6 (was 5); dense-second-beyond-cap steps
past without stalling and still delivers the neighbours. Verified on live relays
(strfry / nostr-rs-relay / khatru): ground-truthing each dense internal second
against the paginated set shows no gaps, incl. a 36-event second fully retrieved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 21:53:48 +00:00
Claude aa8412630e refactor(quartz): collapse fetchAllPages to a single active-filter list
The search-single-page logic left two lists with different roles: the listener
counted matches over the full `pagedFilters` (including a search filter already
dropped from paging) while the subscription only sent `remainingFilters`. That
worked — the dropped filter's count was unused and `advancesCursor` kept its
hits off the cursor — but it read as if a non-subscribed filter still mattered.

Collapse to one `activeFilters` list (index + filter) that is both what we
subscribe and what the listener iterates, so counting can't drift from what was
asked. Behavior is identical; the multi-filter and search tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:42:34 +00:00
Claude 590731f356 feat(cli): add Context.drainAllPages + shared fetchAllPagesFromPool accessory
Amy's one-shot queries all go through Context.drain, a single REQ drained to
EOSE — so a relay that caps its REQ response (strfry's per-REQ limit, ~500)
silently truncates the result with no way to page past it.

Extract the per-relay fetchAllPages fan-out that already lived privately in
EventSync into a reusable quartz accessory, fetchAllPagesFromPool: a
sliding-window pool (maxConcurrentRelays) that paginates each relay on its own
`until` cursor, tags every event with its source relay, and does not dedup
across relays. EventSync now delegates to it (its private downloadPool/
downloadFromRelay are deleted — no behavior change: perRelayFilters is already
ordered by and complete over the relay list).

Add Context.drainAllPages, the paged sibling of drain: same verify+store and
per-relay tagging, but fully draining sets larger than one REQ. Wire it into
`amy fetch` behind --paginate/--all (filter mode only), pushing the limit into
the filter so paging stays bounded. sync (NIP-77) and fetch stay separate
interfaces.

Tests: fetchAllPagesFromPool fan-out/tagging/no-cross-relay-dedup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 20:20:57 +00:00
Claude 54c8cefe69 fix(quartz): don't time-walk a search filter in fetchAllPages
NIP-50 search results are ranked by relevance, not created_at, so paging a
search filter by an `until` cursor silently degrades a top-N search into a
full time-walk of the corpus — and never terminates against a relay that
runs FTS over its whole corpus regardless of `until`.

fetchAllPages now queries a `search` filter on its first page only: it is
dropped from every later page and its hits neither advance nor drag back the
`until` cursor that co-resident non-search filters page with. onNewPage also
moves below the empty-page break so it never announces a page that isn't
fetched. Adds a test proving a search filter returns a single relay page
while a plain filter over the same capped relay still pages through the set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YEbdqCRPkszkGCoi89RMt
2026-07-06 19:58:11 +00:00
Vitor PamplonaandGitHub 4828ba70f4 Merge pull request #3488 from vitorpamplona/claude/hex-bit-slice-methods-ra35l0
Add efficient hex-to-Long conversion utilities for Nostr IDs
2026-07-06 14:30:17 -04:00
Claude e40d8df4d0 feat: add fast hex-to-Long slicing to Hex
Add Hex.toLong64/toLong128/toLong256 (plus the shared readLong helper) to
pack the first 64, 128 or 256 bits of a hex string into a single Long, two
Longs or four Longs. Big-endian, allocation-light, branch-free — 16 table
lookups and shifts per word. Useful as cheap map/set keys or bucket hashes
for 32-byte event ids and pubkeys without decoding to a ByteArray.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019CU1wR6NvQmdmNNsPe9GuN
2026-07-06 18:21:21 +00:00
Vitor PamplonaandGitHub c509014cf6 Merge pull request #3486 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-06 12:09:36 -04:00
vitorpamplonaandgithub-actions[bot] 4eba1b3e32 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-06 15:13:00 +00:00
Vitor PamplonaandGitHub ec4e5324ba Merge pull request #3485 from vitorpamplona/claude/relay-settings-parity-d5ocwr
cli: full relay-settings parity for `amy relay` (noun-first, all list kinds)
2026-07-06 11:10:30 -04:00
Claude 5aae4368da refactor(cli): noun-first amy relay, outbox/inbox NIP-65 facets
Restructure `amy relay` from verb-first `relay add URL --type T` to noun-first
`relay <noun> <verb>`, matching amy's `marmot group …` / `cashu mint …`
convention. The relay-list type is now a required path segment (no implicit
default), and a bare noun lists that bucket.

NIP-65 (kind:10002) is fronted by two facet-nouns, `outbox` (write) and `inbox`
(read), replacing the `--marker` flag. They edit the single 10002 event and
apply the spec's merge rules:
- outbox add R on a read-only R  → both
- inbox  add R on a write-only R → both
- outbox remove R on a both-R    → read  (stays in inbox)
- inbox  remove R on a both-R    → write (stays in outbox)
- dropping the last facet removes R entirely
`relay nip65` shows the combined view; `nip65 remove`/`clear` edit the whole
event.

Other buckets are noun+verb: `relay dm|key-package|search|private|blocked|
trusted|proxy|indexer|broadcast|feeds <add|remove|set|clear|list>`. `set` needs
≥1 URL; `clear` empties. `relay add|remove URL` (no noun) stays as the
transport fan-out (nip65 both + dm + key-package).

BREAKING (cli --json/args): removes `relay add/remove/set --type T` and
`--marker`; `relay list` overview now keys nip65 as `outbox`/`inbox`/`nip65`
and the DM bucket as `dm` (was `inbox`). In-repo harnesses updated
(cache/dm/marmot setup drop `--type all`; cache T5 asserts `.dm`).

Verified end-to-end: merge semantics, encrypted NIP-51 round-trips, facet
set/clear, fan-out, aliases, and error paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 15:02:28 +00:00
Vitor PamplonaandGitHub b4c18d5efb Merge pull request #3482 from davotoula/feat/ps1-save-kind-38192
render PS1 memory-card saves over nostr (kind 38192)
2026-07-06 10:43:46 -04:00
Claude c7bde868e1 feat(cli): require --clear to empty a relay bucket
`relay set --type T` with no URLs is now rejected (bad_args, exit 2) instead
of silently wiping the list — a bare empty `set` is almost always a shell
variable that expanded to nothing. Emptying a bucket is explicit: pass
`--clear` (mutually exclusive with URLs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 14:30:08 +00:00
Vitor PamplonaandGitHub 436d0d93e8 Merge pull request #3484 from nrobi144/fix/desktop-sidebar-nav-overlay
fix(desktop): sidebar nav replaces detail overlay + guard RelayLatencyTracker.sweep against CME
2026-07-06 10:21:36 -04:00
Claude 0319809b8c feat(cli): full relay-settings parity for amy relay
Expand `amy relay` from the 3 transport lists (nip65/inbox/key_package) to
every relay-list bucket Amethyst's relay-settings screen manages, and add
remove/set verbs alongside add/list.

Buckets (kind): nip65 (10002, read/write markers), inbox/dm (10050),
key_package (10051), search (10007), private (10013), blocked (10006),
trusted (10089), proxy (10087), indexer (10086), broadcast (10088),
feeds/favorites (10012). The private NIP-51 lists are signed NIP-44-encrypted
via the quartz event factories, exactly like the app. Local relays (device
pref, no event) and named relay sets (30002) are intentionally out of scope.

New/changed commands:
- `relay add URL --type T [--marker read|write|both]` — `--marker` sets the
  nip65 role; `all` still means nip65+inbox+key_package.
- `relay remove URL --type T` — new.
- `relay set --type T [URL…] [--marker …]` — new; replace a whole bucket
  (no URLs clears it).
- `relay list [--type T]` — lists every bucket, or one.
- `relay publish-lists` — now broadcasts every configured list.

Thin-assembly only: buckets are a small registry over the existing quartz
`create`/`relays` factories; adds one generic `Context.latestReplaceable`
helper. `--json` is additive — legacy keys (`nip65`/`inbox`/`key_package`,
`nip65_event_id`/…) are unchanged, so the existing test harnesses keep passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjHzNewJ2sfBGCcSwe35Mc
2026-07-06 13:59:47 +00:00
nrobi144andClaude Opus 4.7 57a64d258d fix(commons): guard RelayLatencyTracker.sweep against concurrent writes
`putPending` stores each relay's pending map as
`Collections.synchronizedMap(LinkedHashMap(...))` and wraps every writer
path (`putPending`, `recordSent`, `recordIncoming`, `recordDisconnect`)
in `synchronized(perRelay)`. `sweep` iterated `pending.entries.iterator()`
without taking the same lock, violating the wrapper's Javadoc contract.

Any concurrent websocket-thread write during `RelayHealthStore.reclassify`'s
sweep threw `ConcurrentModificationException` on the underlying
`LinkedHashMap$LinkedHashIterator`. Because `reclassify` schedules sweep
on the AWT dispatcher, the CME killed Amethyst Desktop's Compose render
thread and froze the UI.

Wrap both inner iterator loops in `synchronized(pending) { ... }` — the
exact synchronization the wrapper's Javadoc prescribes for manual
iteration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:18 +03:00
nrobi144andClaude Opus 4.7 6361c54a4e fix(desktop): sidebar nav replaces detail overlay instead of hiding behind it
On Desktop, tapping a sidebar nav item while a detail screen (profile,
thread, article, editor) was open only mutated the sidebar destination.
The opaque `AnimatedContent` overlay driven by `ColumnNavigationState`
kept covering the (already-swapped) root content until the user hit
Back, creating the impression that the click did nothing.

Fix: emit a `clearOverlaySignal` from `SinglePaneState.navigate` and
`DeckState.focusExistingColumn`. Each layout collects the signal in a
`LaunchedEffect` and calls `navState.clear()`, draining any pending
detail stack so the tapped destination is what the user actually sees.

- SINGLE_PANE: one signal (Unit), one layout-local `navState`.
- DECK: signal payload is the column id; each `DeckColumnContainer`
  filters on `column.id`, so only the focused column's detail clears —
  other columns' navigation stacks are preserved.
- Same-item taps also clear (signal fires unconditionally, unlike a
  StateFlow value comparison).
- `onOpenSettings` uses the same navigate / focusExistingColumn paths
  and inherits the fix automatically.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 16:08:06 +03:00
David KasparandGitHub b4f9da567f Merge pull request #3479 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-06 01:04:32 +02:00
davotoula 9af611faf1 fix: fetch uncached addressable thread roots; render blank PS1 blocks as empty slots 2026-07-06 00:35:58 +02:00
davotoula 836caa5cd6 Code review:
- Use the app's cached stringRes helper instead of raw stringResource,
  matching the dominant convention in ui/note/types
- Drive the icon animation from the Compose frame clock (withFrameMillis)
  instead of a delay loop, so the ticker suspends whenever the composition
  stops drawing rather than waking the main dispatcher 4x/sec from the
  back stack
- Drop the unconsumed memoryCardId/blockState/blockHash accessors; the
  tag schema stays documented in the class KDoc
- Document the frames arrays as frozen: mutating them in place would
    silently break the @Immutable skip contract; build a new instance
    to change pixels
- Replace the API-29-deprecated Bitmap.createBitmap(IntArray, ...)
    overload with createBitmap(w, h, config) + setPixels
2026-07-06 00:35:58 +02:00
davotoula c0cc2b0245 feat: render PS1 memory-card saves over nostr (kind 38192)
feat: animate the PS1 BIOS save icon on kind-38192 cards
2026-07-06 00:35:58 +02:00
vitorpamplonaandgithub-actions[bot] 74b8a3ad95 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-05 21:31:53 +00:00
Vitor PamplonaandGitHub 567d98cbcb Merge pull request #3481 from vitorpamplona/claude/onchain-wallet-visibility-6mf589
feat: add setting to hide the on-chain (Bitcoin) wallet
2026-07-05 17:29:24 -04:00
Claude e3fd1c53e4 feat: hide on-chain wallet from zap buttons too
Extend the showOnchainWallet preference to the zap flows, which also surface an
on-chain rail:

- the on-chain rail on each zap-amount chip in ZapAmountChoicePopup (folded into
  the shared railCapability so every caller — reaction row, ReusableZapButton —
  is covered)
- the "Send on-chain instead" hand-off button in the custom zap dialog

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UM57Rq5iUPL1SGhzhzTusa
2026-07-05 20:25:51 +00:00
Claude d69d99a8e1 feat: add setting to hide the on-chain (Bitcoin) wallet
Some users don't want the on-chain wallet surfaced. Add a `showOnchainWallet`
UI preference (default true, so behavior is unchanged) that hides it across the
app when turned off:

- the "Bitcoin" card on the Wallet screen (OnchainSection)
- the on-chain chip on profiles (DisplayPaymentRailChips)
- the on-chain rail in the Send Payment screen

The flag lives in the existing UiSettings/UiSettingsFlow display-preferences
system (persisted to the shared-settings DataStore, alongside the other
show/hide profile toggles) and is exposed as a switch on the Profile UI
settings screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UM57Rq5iUPL1SGhzhzTusa
2026-07-05 19:24:48 +00:00
Vitor PamplonaandGitHub c27e977b7a Merge pull request #3480 from vitorpamplona/claude/negentropysynch-nip42-hang-1m4hxb
Fix negentropy refusal handling to prevent window-split storm
2026-07-05 12:09:55 -04:00
Claude 5927c1837e fix(nip77): don't treat a "blocked:" NEG-ERR refusal as an over-cap overflow
A relay that refuses negentropy with a NEG-ERR whose reason merely starts
with "blocked" (e.g. "blocked: Negentropy sync is disabled" from a relay
that has NIP-77 turned off, or an auth/ban refusal) was misclassified as a
strfry `max_sync_events` overflow by `isOverflow`. Overflow triggers
created_at window-splitting, so every split re-opened, was refused again,
and the splitter fanned out breadth-first across the whole created_at range
(~2^31 windows). The call therefore never threw NegentropySyncException (so
`negentropySyncOrFetch` never took its paging fallback) and never tripped
the idle watchdog (the relay answered every NEG-OPEN promptly), so it hung
indefinitely. A second relay whose refusal string did not start with
"blocked" fell through to `Failed` -> paging and completed, which is why the
two behaved differently despite advertising the same NIPs.

Narrow `isOverflow` to genuine "result set too large" signals only; a bare
`blocked:` refusal now maps to a hard failure and fails over to paging.

Adds a regression test driving an in-process relay that refuses every
NEG-OPEN with "blocked: Negentropy sync is disabled" while still serving
plain REQ: negentropySyncOrFetch now pages and delivers every event instead
of hanging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UF3eh76rRiwAuPg32rwiz
2026-07-05 16:08:25 +00:00
Vitor PamplonaandGitHub 4a13a15f6c Merge pull request #3478 from vitorpamplona/claude/benchrelay-1m-events-test-v6vtp0
perf(relay): geode↔strfry 1M sync — negentropy + store speedups, strfry-parity mirror, benchmark harness
2026-07-05 10:14:19 -04:00
Claude 8efe8af2dc refactor(quartz): move NDJSON import/export into Quartz as store logic
The `import`/`export` engine is pure protocol/store logic — it operates only on
the `IEventStore` interface and Quartz event types (Event, OptimizedJsonMapper,
verify, Filter), with zero geode dependency — so per the sharing philosophy
("quartz = Nostr business logic, protocol, data") it belongs in Quartz, not in
the geode app. Any Quartz consumer (a relay, the `amy` CLI, a desktop
backup/restore) can now reuse it.

- move `com.vitorpamplona.geode.ImportExport` →
  `com.vitorpamplona.quartz.nip01Core.store.NdjsonImportExport` (commonMain,
  next to IEventStore); rename for a clear library-level name.
- geode keeps only the CLI glue (verb dispatch, arg parsing, file/stdin/stdout,
  the stderr summary) in Main.kt, delegating to the Quartz engine.
- move the test into quartz jvmTest, rebuilt on Quartz's own EventFactory +
  NostrSignerSync (real Schnorr signing) instead of geode fixtures.

No behavior change — `geode import`/`export` work exactly as before (verified
end-to-end previously); this is purely where the code lives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 14:10:16 +00:00
Claude 5db2543cfc feat(geode): add import / export NDJSON verbs; drop the benchmark-only server
Bulk NDJSON import/export as first-class geode subcommands, mirroring
`strfry import` / `strfry export` (one JSON event per line — the interchange
format for seeding a relay, migrating between relays, or taking a backup):

  geode import [--db …] [--no-verify] [FILE…]   # files, or stdin when none
  geode export [--db …]                          # NDJSON to stdout

Both stream — memory is bounded to one batch (import) / one event (export), so
a multi-million-event corpus round-trips in roughly constant memory. `import`
verifies signatures by default (same `Event.verify()` the relay's VerifyPolicy
uses), upholding the relay's verify-by-default stance rather than trusting the
file; `--no-verify` is the trusted-input escape hatch. Verb dispatch is
backward-compatible: a bare `geode --port …` (no verb) still serves.

This makes the benchmark-only `CorpusServerMain` redundant — a corpus source is
now just `geode import` into a DB, then a normal `geode` serve — so it's
deleted, removing benchmark-only code from the production geode artifact (the
question that started this). The 1M sync-throughput plan is updated to describe
sources via `geode import` + serve.

Also fixes a native-target CI break: MergeQueryCorrectnessTest used the
deprecated `String(CharArray)` (error-level on Kotlin/Native) — switched to
`CharArray.concatToString()`.

Verified end-to-end through the packaged `geode` binary: import (file + stdin,
--no-verify), export round-trip, and verify-on rejecting bad signatures.
ImportExportTest covers the counts, duplicate handling, malformed-line
skipping, and verify accepting a freshly-signed event while rejecting bad sigs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 13:56:11 +00:00
Claude cece5b6e04 docs: sync comments/plans with the audit fixes
Follow-up to the audit fixes so nothing describes the pre-fix behavior:

- CorpusServerMain: drop the leftover "reuses an already loaded DB … skips
  the reload" comment above `val dbFile` — the sentinel-gated reuse it
  described is now spelled out in the block just below it.
- sync-throughput-1m plan: the up-catch-up now streams `negentropyReconcile`
  (publishing each onHaveIds batch) instead of materializing the full diff
  via negentropyReconcileIds; note the O(batch) memory win at 1M.
- follow-feed plan: the k-way merge dedups repeated authors/kinds, and its
  id-ASC tie-break is byte-exact vs the single-SQL path only when the store
  indexes id (useAndIndexIdOnOrderBy) — otherwise ties fall in rowid order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 12:20:11 +00:00
Claude 2ce3e2bf5c Merge remote-tracking branch 'origin/main' into claude/benchrelay-1m-events-test-v6vtp0
# Conflicts:
#	gradle/libs.versions.toml
2026-07-05 12:13:37 +00:00
Claude c863812b1e fix(relayBench): stop the corpus tools silently serving/keeping wrong events
Two benchmark-integrity bugs that could invalidate sync numbers while a run
reports success:

- CorpusServerMain keyed its serve-existing decision only on port + row
  count, so re-running a port with a different corpus/maxCount, or after a
  load crashed mid-way, silently served a stale/partial DB. Gate reuse on a
  completion sentinel keyed on corpus identity (path + byte length) and
  maxCount, written only after a full load; on any mismatch the prior DB is
  dropped and reloaded.
- CorpusDownloader treated CLOSED identically to EOSE, so a relay ending a
  sub early (rate-limit/policy) after sending a partial page advanced the
  cursor past the unsent tail — silent corpus loss. Treat CLOSED as a soft
  failure (null → reconnect and retry the same cursor; the id dedup set
  absorbs the re-fetch), distinct from EOSE which means the page is
  complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:37 +00:00
Claude d567aec6de perf(geode): stream haveIds in the mirror up-catch-up instead of materializing
runCatchUpUp used negentropyReconcileIds, which builds the FULL need+have
id lists in memory even though the up direction only needs haveIds — on a
large window (e.g. 1M local events against an empty upstream) that is a
~100 MB+ heap spike per convergence round, plus a needIds list built and
immediately discarded.

Switch to the streaming negentropyReconcile: publish each haveIds batch as
it arrives (bounded to one batch of ids) and drop the need direction via a
no-op onNeedIds. The publish still suspends the reconcile round, so the
back-pressure and the reconcile-as-delivery-check convergence loop are
unchanged — only the peak memory drops from O(window) to O(batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:36 +00:00
Claude 7aa6144dd1 fix(store): make k-way merge honor id tie-break and dedup repeated authors
MergeQueryExecutor's winner-picker tie-breaks equal created_at by id ASC,
but each per-stream cursor sorted by created_at DESC only, and repeated
authors/kinds opened duplicate cursors:

- id tie-break: thread the IndexingStrategy through run()/prepareStreams
  and append ", id ASC" to the per-stream ORDER BY when
  useAndIndexIdOnOrderBy is set — matching every sibling query in
  QueryBuilder. The id-indexed order comes straight off the index (no
  extra sort, lazy cursor preserved), so the merge now matches the
  single-SQL path byte-for-byte on same-second same-author events. Without
  the id index the tie stays in rowid order (a valid NIP-01 newest-N);
  documented on the class.
- dedup: streamCount/prepareStreams now operate on distinct authors and
  kinds, so a filter with a repeated pubkey can no longer open two
  identical cursors and emit each matching event twice (the single-SQL
  IN(…) path already dedups).

Adds two MergeQueryCorrectnessTest cases the suite was missing: a
within-stream same-second tie sliced by the limit, and duplicate authors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 04:55:19 +00:00
Claude 5dd9fc5266 docs(relayBench): add REQ-mode 1M pull results alongside negentropy
geode paged-REQ sink (verify on, FTS off):
  geode→geode  4,963 ev/s (completes)   strfry→geode  5,801 ev/s (completes)

Findings: strfry has no REQ bulk sync (stream is live-only limit:0), so the
strfry-sink REQ pairs are n/a. strfry→geode over REQ now completes (earlier
FTS-on stall was strfry killing the slow client at its 32MB pending cap; FTS-off
keeps pace). REQ is ~15-30% slower than negentropy for the geode sink, and
geode-as-a-REQ-source is slower than strfry (SQLite range scans vs LMDB).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 03:43:43 +00:00
Claude 59641ba1ef docs(relayBench): record the 4-pair 1M negentropy sync comparison
All four source→sink pairings via NIP-77, geode sink configured like strfry
(verify on, FTS off), run sequentially:

  geode→geode   6,971 ev/s   strfry→geode  6,690 ev/s
  strfry→strfry 2,682 ev/s   geode→strfry  2,127 ev/s

Key findings: the sink sets the rate (geode ~6.7-7.0k, strfry ~2.1-2.7k from
either source — geode ingests ~2.6-3.3x faster); full geode↔strfry negentropy
interop both directions; strfry-source count is lower because its negentropy
snapshot drops NIP-40-expired events (geode, no cutoff, offers its full set).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:57:37 +00:00
Claude 6058c9943c test(geode): CorpusServerMain serves an already-loaded DB (skip reload on re-run)
Reuse the file-backed source DB when it already holds events instead of
deleting + reloading the corpus every boot, so re-running a single sync pair
skips the multi-minute load.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:50:25 +00:00
Claude 36571cfb94 test(geode): sync-benchmark knobs + corpus-server tool for the negentropy comparison
- MirrorSyncThroughputTest: add -DsyncVerify (default true — `strfry sync` always
  verifies received events, so the negentropy sink verifies too for an
  apples-to-apples comparison) and -DsyncFts (default true; pass false to match
  strfry, which has no NIP-50). Both forwarded through the geode test task.
- CorpusServerMain: a benchmark-only tool that boots a real geode relay
  (geode's default indexing) preloaded with an NDJSON corpus over a file-backed
  store and serves forever, so `strfry sync` / another geode / the negentropy
  sink can reconcile against a geode source holding the same 1M corpus a strfry
  source does.

Used to run the 4-pair 1M negentropy sync comparison (geode↔geode, strfry→geode,
strfry↔strfry, geode→strfry).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:29:58 +00:00
Claude 4949d58d17 feat(geode): add up-direction negentropy catch-up (strfry sync --dir up parity)
strfry's `sync --dir` is bidirectional (its source: doUp = both||up,
doDown = both||down), but geode's catch-up was down-only. Add the up half so
`dir=up`/`dir=both` reconcile-and-push matches `strfry sync --dir both`.

runCatchUpUp reconciles the local set against the upstream (negentropyReconcileIds)
and publishes the events we hold that the upstream lacks (the reconcile's `have`
ids). Symmetric to the down catch-up: same one `dir`, live up-session starts at
`now` when the up catch-up covers history.

Reliability: client.publish's outbox is best-effort under a bulk burst (each
publish also churns a reconnect — measured ~1-2% dropped per pass), so the push
runs as a reconcile→push convergence loop. Each round re-reconciles — the
reconcile IS the delivery check against the upstream — and re-pushes only the
stragglers until the have-diff is empty. Test observed 3000 → 69 → 2 → 0 across
3 rounds, lossless.

Test: MirrorNegentropyCatchUpTest.negentropyCatchUpPushesUp pushes 3000 local
events to an empty (no-verify) sink and asserts all 3000 land. The four existing
mirror tests still pass (up catch-up needs a store + negentropyBackfill, both off
by default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 02:01:05 +00:00
Claude 96674e7ce4 feat(geode): mirror strfry's two-phase model — NIP-77 sync catch-up + live REQ tail
geode's MirrorWorker mirrored `strfry router` (live REQ streaming) but had no
`strfry sync` equivalent, so backfilling a large foreign relay from empty could
not complete: a plain REQ dump of the history overruns the sink and strfry kills
the slow client at its maxPendingOutboundBytes cap (see
relayBench/plans/2026-07-04-sync-throughput-1m.md).

MirrorWorker now runs a one-shot NIP-77 "sync" catch-up per down/both upstream
before the live tail, using strfry's own vocabulary — one `[[mirror]]` entry,
one `dir` driving both phases:

- Catch-up reconciles the local set against the upstream over the
  [now - backfill_seconds, now] window and downloads only the diff via the
  existing INostrClient.negentropySyncOrFetch — client-paced (strfry can't
  overrun us) and it completes the pull. Reconcile-against-local means a warm
  restart re-fetches nothing it already holds, like `strfry sync`.
- Either mode, transparently: negentropySyncOrFetch auto-falls back to paged
  REQ for an upstream without NIP-77 — no config toggle.
- Live REQ tail unchanged; it starts at `now` when catch-up is on (history is
  the sync's job). The windows overlap at `now`; the store's unique-id
  constraint dedups the seam.

Changes:
- quartz: add a backward-compatible `localEntries` param to the public
  negentropySync / negentropySyncOrFetch (default empty = prior behavior) so the
  reconcile diffs against a caller-supplied local set.
- geode MirrorWorker: `runCatchUp()` (bounded, backpressured ingest; same
  trusted-scope re-check as the live path; failure is non-fatal). New `store` +
  `negentropyBackfill` ctor params; default off so existing live-REQ tests are
  unchanged. Main opts production in.
- Test: MirrorNegentropyCatchUpTest isolates catch-up from the live tail by
  preloading historical events a live-only sub cannot deliver, then proves the
  post-boot event still arrives (3000 catch-up + 1 live = 3001).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 01:33:47 +00:00
Claude 4c8066c54c test(geode): add negentropy strfry→geode sync — completes where REQ stalls
Adds a NIP-77 negentropy client path to MirrorSyncThroughputTest (default for
external sources; `-DsyncMode=req` keeps the paged-REQ drain). geode reconciles
its empty set against strfry, then client-paced fetch-by-ids + ingest.

Result on the 1M damus.io corpus (strfry source):
- reconcile (empty local → 995,024 need-ids): 11.4 s, 64 rounds.
- fetch + ingest: ~7,000 ev/s steady-state.
- overall: 994,936 / 997,980 in 171.0 s => 5,818 ev/s — COMPLETES.

This is the apples-to-apples counterpart to strfry→strfry's `strfry sync`
(both negentropy, same corpus, empty→full): strfry→geode 5,818 ev/s vs
strfry→strfry ~2,550 ev/s — geode ingests real content ~2.3x faster and
finishes the pull. The paged-REQ path, by contrast, stalls at ~310k every time
(strfry kills a slow REQ client at its 32 MB maxPendingOutboundBytes cap),
confirming that cross-relay bulk sync from strfry requires negentropy, not REQ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-05 00:40:33 +00:00
Claude 8fbca75850 test(geode): measure 1M sync throughput strfry↔strfry, geode↔geode, strfry→geode
Adds a strfry→geode drain mode to MirrorSyncThroughputTest and documents the
three-way 1M-corpus sync throughput comparison.

Findings (relayBench/plans/2026-07-04-sync-throughput-1m.md):
- strfry→strfry (strfry sync / negentropy): ~2,550 ev/s, completes.
- geode→geode (MirrorWorker REQ): 13,161 ev/s, completes — but inflated by
  synthetic 4-byte content; real-content ingest is ~7,000 ev/s.
- strfry→geode (paged REQ drain, real corpus): sustains ~7,000 ev/s but does
  NOT finish — strfry hard-kills a slower REQ client at its 32 MB
  maxPendingOutboundBytes cap (~310k events in; confirmed by the source log's
  "Pending: 32.01M" disconnects). Independent of heap (2G/10G) and the live
  negentropy index (on/off).

Conclusion: strfry's REQ serving is structurally hostile to any client slower
than its scan (buffers outbound, then kills or OOMs). Only NIP-77 negentropy —
pull/reconcile-based and client-paced — completes a cross-relay bulk pull, which
is why strfry's own sync uses it. A production geode backfill from a foreign
relay should use negentropy, not the live-tail REQ path.

Also documents MirrorWorker's unbounded intake channel (a deliberate live-tail
trade) OOMing under a 1M bulk backfill, and forwards the sync* system
properties through the geode test task.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 23:55:58 +00:00
Claude bbd8b3a596 perf(nip77): stop the live index turning bulk backfill into O(n^2)
LiveNegentropyIndex kept a sorted ArrayList and paid an O(n) element shift per
incremental insert. That's cheap for near-tail live traffic (created_at ≈ now),
but a mirror/import backfill delivers historical, out-of-order events, so every
insert memmoves ~n/2 entries and the whole sync goes O(n^2) — a geode→geode 1M
mirror crawled to <300 ev/s once the index passed ~130k, versus a sustained
~20k ev/s with the index off.

When an insert lands more than REBUILD_THRESHOLD (4096) from the tail, drop the
index instead of shifting: it rebuilds in one O(n log n) scan on the next
NEG-OPEN (liveNegentropySnapshot already does this when unpopulated), and while
unpopulated newDeltaOrNull skips delta tracking, so backfill costs O(1) per
event. Near-tail live inserts keep the cheap incremental path. NIP-77
convergence and byte-exact tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:45:43 +00:00
Claude 91a7261555 test(sync): toggle live negentropy index to isolate its O(n)-insert backfill cost
Adds -DsyncLiveIndex and a fast (no-live-index) source preload. Rate curves show
geode's mirror sustains ~20k ev/s with the live index off, but collapses to
O(n^2) with it on: the LiveNegentropyIndex is a sorted ArrayList whose per-event
insert is O(n), and a backfill delivers historical (non-near-tail) events, so
every insert memmoves ~n/2 entries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:41:13 +00:00
Claude 847d5f47e6 test(sync): use geode's real RelayIndexingStrategy + live rate logging in throughput test
Default EventStore(null) tokenizes FTS synchronously on every insert, which
dominates ingest and misrepresents mirror sync throughput. Use geode's actual
RelayIndexingStrategy (deferred FTS, live negentropy index) for both source and
sink, and log instantaneous events/s every 3s so the rate is visible during the
run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:34:23 +00:00
Claude 334c4b622c test(sync): use an OS-assigned port for the throughput source to avoid bind clashes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 22:03:40 +00:00
Claude 6095e5768e test(sync): 1M sync-throughput harness for strfry↔strfry, geode↔geode, strfry→geode
Two pieces measuring how fast each sink pulls a large corpus from a source using
its native sync client:

- relayBench/sync-throughput-strfry.sh: `strfry import` N events into a source
  strfry, boot it, `strfry sync --dir down` an empty sink, report events/s.
- MirrorSyncThroughputTest: geode downstream pulls via the real MirrorWorker
  (WebSocket). Default in-process geode source (geode→geode); with
  -DsyncSourceUrl it mirrors an external relay (e.g. strfry) for strfry→geode.
  Sized by -DsyncN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:57:49 +00:00
Claude 998e5fc422 docs(relayBench): record that the sync shortfall is a harness artifact, not geode
The ~19/40k event shortfall in the geode↔geode sync is not a geode event-loss
bug: geode is proven lossless across the store, the concurrent IngestQueue
pipeline, RelaySession (OK-true only post-commit), and the real MirrorWorker
WebSocket path (50k/50k). The shortfall is in the benchmark's hand-rolled
fetchByIds+publish delta transfer. Points the fix at the harness (or at driving
convergence through geode's real mirror) rather than geode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:32:26 +00:00
Claude 062e725bb5 test(geode): prove real mirror sync is lossless over the WebSocket transport
Streams 50k events from an upstream KtorRelay to a downstream via the production
MirrorWorker (real OkHttp WebSocket + trusted skipVerify ingest) and asserts the
downstream receives every one — 50000/50000, 0 missing.

This closes the last untested layer: the in-process guards (BatchInsertLossTest,
ConcurrentIngestLossTest) call IngestQueue.submit directly, bypassing the wire.
With this, geode is proven lossless end-to-end — store, concurrent pipeline,
background pool contention, RelaySession, and the real WS mirror path. The
~0.05% shortfall seen in the geode↔geode relayBench sync is therefore an
artifact of the harness's hand-rolled fetchByIds+publish delta transfer, not a
geode event-loss bug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:30:34 +00:00
Claude a7d549b579 test(store): guard that concurrent ingest (queue + pipeline + bg writers) is lossless
Drives the full IngestQueue pipeline — parallel verify, greedy-drain group
commit, a concurrent deferred-FTS catch-up worker taking the pool writer, and
windowed concurrent submits via the trusted (skipVerify) mirror path — over the
clean 200k corpus, asserting every Accepted regular event is queryable after.
Passes (199,612 in/accepted/stored, 0 lost), together with BatchInsertLossTest
proving geode's ingest is lossless at every in-process layer. The geode↔geode
sync event-loss therefore lives above the store+queue — in the real Ktor
WebSocket path or the benchmark harness's hand-rolled delta transfer, which the
in-process paths bypass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 21:26:33 +00:00
Vitor PamplonaandGitHub adda77e586 Merge pull request #3477 from davotoula/fix/ots-equals-hashcode
Repair equals/hashCode contracts in OpenTimestamps ops and VerifyResult
2026-07-04 17:03:35 -04:00
Claude 12e4e725c7 test(store): guard that sequential batchInsert never loses accepted events
Drives the exact store path — batchInsertEvents with geode's indexing strategy
in 64-event batches — over the clean 200k corpus and asserts every Accepted
kind-1 (regular, never replaced/deleted here) is queryable afterward. Passes
(199,612 in, 199,612 stored), which is the point: it proves the sequential
store path is lossless and narrows the geode↔geode sync event-loss to the
concurrent IngestQueue pipeline (async verify + greedy-drain batching +
concurrent WS submits), not the store itself.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:31:05 +00:00
Claude 8ae8ad7529 test(relayBench): instrument the delta-transfer path to localize event loss
Report, per sync pair: fetch coverage (did the peer REQ return every needed id,
without duplicates?), publish acks (accepted/rejected/unacked per target), and —
for any event a relay ends up missing — whether it was in the delta batch
delivered to that relay. In-batch-but-absent isolates ingest loss
(ack-without-persist) from a fetch/read gap, turning a vague "missing N" into a
pinned layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:12:26 +00:00
Claude 7f51a43dad docs(relayBench): note the NIP-62/09/40 sync-fairness gap in effectiveEvents
The harness's reconcile reference only collapses replaceable kinds; it ignores
NIP-62 Request to Vanish, NIP-09 deletions, and NIP-40 expiration. So a
compliant relay (geode, which honors NIP-62) is falsely flagged "did not
converge" and the phantom events inflate its reconcile round count, masking
negentropy speedups at the wire. Documents the symptom (traced to one
ALL_RELAYS vanish pubkey in the damus corpus), the census (7 vanish pubkeys,
381 expiry events, 0 deletions in the first 200k), and the fix design for
whoever implements it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 20:00:16 +00:00
Claude 937ddd7fe2 test(relayBench): name the events a relay is missing when a sync doesn't converge
When the identical-set reconcile shows a side missing events the reference set
has, log those events grouped by kind (plus a few samples with their tag keys)
instead of leaving a bare "did not converge". The harness already holds every
event by id, so it can resolve exactly what a relay dropped — turning a vague
verdict into an actionable ingest-semantics diagnosis (NIP-09 deletion / NIP-40
expiration / validation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 19:45:01 +00:00
davotoula 489e8ae50b Code review:
- enforce op equality by class and pin the tag-uniqueness invariant
- hoist crypto op equals/hashCode onto OpCrypto
2026-07-04 21:38:08 +02:00
davotoula 772b4ea8ed fix(quartz): repair equals/hashCode contracts in OTS ops and VerifyResult
Op instances key Timestamp.ops (MutableMap<Op, Timestamp>), so contract
violations corrupt hash-map behavior:

- OpKECCAK256 defined equals without hashCode, so equal instances hashed
  by identity — two equal keys could land in different buckets, producing
  duplicate branches or failed lookups in keccak256 timestamp trees. Add
  hashCode = TAG, mirroring OpSHA1/OpSHA256/OpRIPEMD160.
- OpBinary defined hashCode without equals — and its TAG referenced
  Op.TAG (0x00), a no-op XOR. Define the equals/hashCode pair once on
  OpBinary using tag() and drop the duplicated overrides from
  OpAppend/OpPrepend (behavior unchanged: same tag + same arg content).
- VerifyResult.equals cast without a type test (ClassCastException on
  foreign types instead of false) and hashCode force-cast the nullable
  timestamp (NPE for null-timestamp results). Convert to a data class;
  the custom toString and compareTo stay.
2026-07-04 21:37:24 +02:00
Claude 469220de77 perf(nip77): bump kmp-negentropy to v1.2.0 (faster reconcile/fingerprint internals)
v1.2.0 (on Maven Central) speeds up the library's own reconcile and fingerprint
walk on top of the v1.1.1 PrefixSumStorageVector wiring. At the 1M relayBench
slice shape (NegentropyReconcileBenchmark, converges exactly, need/have=200k):
client reconcile 264 → 178 ms, seal 424 → 320 ms, and the library's O(range)
fingerprint walk 447 → 252 ms (~1.8×). Our prefix-sum path still answers each
range fingerprint in 0.7 ms (356× the now-faster walk). All NIP-77 tests pass.

Update the reconcile-profiling plan: the fix shipped via the upstream
IStorage.fingerprint seam (v1.1.1) rather than a quartz-side fast server; record
the v1.0.2 → v1.1.1 → v1.2.0 progression.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 19:33:54 +00:00
Claude 24dc3133ec fix(relayBench): raise harness heap to 8g so the 1M sync phase doesn't OOM
`SyncBenchmark.effectiveEvents` materializes the whole corpus as Event objects
plus a dedup LinkedHashMap to derive the 80% slices, so a million-event run
blew past the 2g default with an OutOfMemoryError before the negentropy sync
could start. -Xmx is a ceiling, not a reservation, so smaller runs don't pay
for the higher limit; JAVA_OPTS still overrides it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 18:51:21 +00:00
Vitor PamplonaandGitHub db14f7f921 Merge pull request #3476 from vitorpamplona/claude/ci-localrelaystorehydration-test-oh1ljr
fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction
2026-07-04 13:49:19 -04:00
Claude 9bb1d3aaf2 fix: stabilize flaky LocalRelayStoreHydrationTest against GC eviction
DesktopLocalCache stores Users in a WeakReference-backed LargeSoftCache. The
followee User in kind3IsHydratedBeforeKind0SoMetadataLoadsForFollowedAuthors is
created only during hydrate's kind:0 phase and has no Note referencing it, so it
is only weakly reachable once hydrate returns. A GC landing between hydrate()
and the assertions evicted it, flaking the test (reproduced deterministically by
forcing System.gc()).

Pin a strong reference to the followee's User for the duration of the test so
the cache cannot evict it, mirroring how followed users stay reachable via live
account/UI state in the running app. The ordering invariant the test asserts is
unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NHQ3g7wD9WbDvj7NspiAWW
2026-07-04 17:48:18 +00:00
Claude 042b6a0c76 perf(nip77): use kmp-negentropy v1.1.1 PrefixSumStorageVector for O(1) range fingerprints
kmp-negentropy v1.1.1 adds the `IStorage.fingerprint(begin, end)` seam we needed
and ships `PrefixSumStorageVector` — a drop-in IStorage that builds an additive
prefix-sum table on seal() and answers any range fingerprint in O(1) instead of
re-walking the range. Range fingerprints are the CPU-bound core of a NEG-MSG on
a large snapshot; profiling pinned them as the steady-state reconcile cost geode
lost multiples on (not serialization).

Seal a `PrefixSumStorageVector` in both `NegentropyServerSession.sealVector`
(server / relay-relay responder, also backs the `LiveNegentropyIndex` snapshot
cache) and `NegentropySession` (initiator). Byte-identical to the plain vector —
only the fingerprint path is accelerated. `NegentropyPrefixFingerprintTest` now
also asserts the library's `PrefixSumStorageVector.fingerprint` matches the plain
walk over 2000 random ranges + boundaries at 50k; the reconcile-shaped mix
measures 601× (447 ms → 0.7 ms).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 17:19:46 +00:00
Claude 9ac5106bc0 fix(store): compile the merge raw-path correctness check; validate follow-feed at 1M
The `rawQueryPathMatchesDecodedQuery` case called `store.rawQuery(filter)`, but
`EventStore` only exposes the streaming `rawQuery(filters, onEach)` — the
list-returning overload lives on the inner `SQLiteEventStore`. Point the check
at `store.store.rawQuery(filter)` so the zero-decode path is actually exercised.

Record the shipped k-way merge result in the plan doc: a fresh 1M relayBench run
has geode `follow-feed` at 18.8 ms vs strfry 17.7 ms (down from 97.7 ms, now at
parity) and 46,258 ev/s vs strfry 15,365 @8conn, both returning the same 500.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 17:19:32 +00:00
Vitor PamplonaandGitHub b7912244fb Merge pull request #3475 from vitorpamplona/claude/negentropy-kmp-update-s5m8cw
Upgrade negentropyKmp to v1.1.1
2026-07-04 12:49:20 -04:00
Claude 8d09671218 perf(store): k-way merge for the home-feed REQ shape
The home-feed REQ (`authors=[…] (+ kinds=[…]) [+ since/until] limit=N`,
newest-first) is one of the most common relay queries. SQLite serves it by
seeking every `(kind, pubkey)` combo and feeding *all* matching rows through a
LIMIT-bounded sorter, so it reads O(the followed set's whole matching history)
— on a cold on-disk 1M corpus that was the `follow-feed` regression (relayBench:
97.7 ms vs strfry 17.6 ms).

Add `MergeQueryExecutor`, an app-level k-way merge that opens one lazy
newest-first cursor per stream off the existing composite indexes
(`query_by_kind_pubkey_created`, or `query_by_pubkey_created` for authors-only),
merges their heads `(created_at DESC, id ASC)` and stops at the limit — reading
only O(limit + streams) rows regardless of how much history the authors have.
It reuses indexes that already exist, so write throughput and on-disk size are
untouched. Eligibility is narrow (2..2048 streams, simple filter, explicit
limit, no ids/d-tags); everything else falls through to the single-SQL plan.

Wired into both `query` and the zero-decode `rawQuery` paths (the relay REQ hot
path) and the single-element filter-list variants, so `LiveEventStore` REQs go
through it.

`MergeQueryCorrectnessTest` proves the merge returns exactly the single-SQL
top-N — vs an independent Kotlin reference and vs the SQL path — across
distinct/tied created_at, since/until windows, authors-only, fewer-than-limit,
streaming onEach, and the raw path. `FollowFeedReadBenchmark` gains a `merge`
variant: at 1.05M events it's flat ~10-12 ms across both prolific-recent and
sparse-old, where `scan` is catastrophic on sparse follows (1995 ms) and
`current` is disk-bound on prolific ones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 16:49:13 +00:00
Claude bb6bf99586 chore: update negentropy-kmp to v1.1.1
Bumps the negentropy-kmp dependency from v1.0.2 to v1.1.1. The release is
backward compatible for consumers (StorageVector still provided; the codebase
only consumes IStorage and never implements it). Quartz compiles and all
NIP-77 negentropy tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M3659Rs6ayVm4XmxvbtpWE
2026-07-04 16:21:55 +00:00
Claude f29196f0fc test(store): measure follow-feed read/write/size tradeoff — keep current plan
follow-feed (kinds=[1,6] × 150 authors, ORDER BY created_at DESC LIMIT 500)
was geode's 5.5× loss (97.7ms vs strfry 17.6ms). Investigated whether any
change is worth it, across read + write + size.

Read (FollowFeedReadBenchmark, in-memory, scale 5 ≈ 1.05M events):
                  prolific-recent   sparse-old
  current            5.7 ms          1.9 ms
  scan (strfry)      1.0 ms       1601.9 ms
  union            316.9 ms         20.0 ms

- scan (created_at index + early LIMIT) wins for active follows but is
  catastrophic for sparse/inactive follows AND grows with corpus size
  (234ms→1601ms from scale 1→5) — following rarely-posting accounts is
  common, so it'd be a severe regression.
- union (300 per-branch subqueries) is dominated by branch overhead.
- current is the only robust option — flat across scale, bounded by the
  followed set, never catastrophic. The 97.7ms is a worst case (the 150
  MOST prolific authors, disk-bound reading all their matching rows).

No safe SQL-level swap exists; each alternative trades geode's worst case
for a worse one on a common workload. The only universal improvement is
strfry's app-level k-way merge (O(LIMIT+streams)) — a real new executor,
not a SQL tweak.

Write & size: neutral for every candidate — all reuse existing indexes
(query_by_kind_pubkey_created / query_by_created_at_id), none adds a
CREATE INDEX, so ingest throughput and storage are untouched regardless of
choice. A new index was considered and rejected (taxes every write, helps
one shape, reverts under ANALYZE).

Decision: keep the current composite plan. Full write-up in
quartz/plans/2026-07-04-follow-feed-read-tradeoff.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 16:04:20 +00:00
Vitor PamplonaandGitHub df01354fa5 Merge pull request #3474 from davotoula/fix/unchecked-file-delete-results
Handle unchecked File.delete() return values across all modules
2026-07-04 11:59:21 -04:00
davotoula afe8c783c0 test(commons): cover deleteOrWarn and restrictToOwner helpers 2026-07-04 17:48:34 +02:00
Claude 3a68927829 test(store): measure why the profiles fix pins the index vs adding one
Answers 'could a new index beat the INDEXED BY pin without the pin?' for
the profiles shape (kind=0 AND pubkey IN(...) ORDER BY created_at DESC).
Measured:

- stock unhinted: scans query_by_kind_created (1.40ms) — the bug.
- pinned composite: seek + tiny sort (0.23ms) — the shipped fix.
- new (pubkey,kind,created_at) index, unhinted: STILL scans — no help.
- new (kind,pubkey,created_at ASC) index, unhinted: picks the seek (0.23ms)
  BUT that's a no-stats cost-model artifact — ANALYZE reverts it to the
  scan (1.47ms). Fragile, and a full duplicate of the DESC composite.

Root reason: ORDER BY created_at over a multi-value pubkey IN(...) needs a
sort no matter the index (no B-tree gives global created_at order across
pubkeys), and SQLite prefers the one sort-free plan — the full-kind scan.
Only the explicit pin reliably overrides that. A new index would add
write+storage cost on every event for the whole relay, help only this one
shape, and break under ANALYZE — so the free, deterministic, scoped pin is
strictly better. Diagnostic evidence for the design choice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:47:37 +00:00
Claude b9d7ea2574 perf(nip77): direct-build NEG-MSG wire frames (~2.5–2.8× serialization)
The server's per-round reconcile spends a large slice turning the ~1MB hex
reconcile frame into wire JSON: the generic (Jackson) serializer wraps the
hex string in a value node and scans every char for JSON escapes a
[0-9a-f] payload can never contain, then re-copies.

NegMsgMessage.toJson() now builds ["NEG-MSG","<sub>","<hex>"] directly —
no node tree, no escape scan of the hex. Fast path fires only for
escape-free printable-ASCII subIds (what the JSON encoder emits verbatim);
exotic subIds fall back to the generic serializer, so output is
byte-identical. RelaySession.send routes through message.toJson() (default
unchanged for every other message type).

Measured (toJson + UTF-8, per frame): 64KiB 2.5×, 250KiB 2.6×, 500KiB
(strfry cap) 2.8× — ~2.5ms saved per NEG-MSG, ~35ms over a 14-round
reconcile. Correctness: a subId battery asserts byte-identity with the
generic path, and GeodeVsStrfryNegentropySyncTest (real strfry) reconciles
against the fast-built frames.

Also records the ingest-latency candidate as measured-not-worth-it: the
IngestQueue pipeline overhead is only ~0.17ms p50, <10% of the ~2.4ms
receipt→queryable gap — that gap lives in the REQ-visibility path, not the
writer. Full write-up in
quartz/plans/2026-07-04-sync-serialization-and-ingest-latency.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:40:26 +00:00
Claude b97c74152a test(perf): add ingest-latency and NEG-MSG serialization benchmarks
Measurement harnesses for the two remaining relayBench gaps, isolating each
cost so a fix can be judged on the delta:

- IngestLatencyBenchmark: times single-event submit→onComplete through the
  group-commit IngestQueue vs a direct batchInsert. The delta is the
  pipeline's coroutine-handoff overhead — the receipt→queryable latency the
  1M run measured geode losing (4.68ms vs strfry 2.32ms).
- NegMsgSerializationBenchmark: times the NEG-MSG wire path (Hex.encode →
  MessageKSerializer JsonElement tree → UTF-8) against a direct StringBuilder
  build, asserting byte-identical output. Isolates the ~40% serialization
  slice of the server reconcile the JFR flagged.

Both compile and are self-contained; results + fixes to follow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:23:35 +00:00
Claude ab32b21ec7 test(store): audit query plans for all relayBench scenarios
After the profiles fix, sweep every relayBench query shape with EXPLAIN
QUERY PLAN against a realistic multi-kind, tagged store to check for other
scan-that-should-seek mis-costs. Result: clean — every scenario seeks an
index (or does an index scan + LIMIT, like firehose's created_at walk).
The remaining TEMP B-TREE sorts are all over bounded result sets
(author/id count or LIMIT), not the profiles-scale pathology. The tag
queries (thread/notifications/hashtag) do an inherent subquery + sort;
hashtag is the slowest scenario but strfry is equally slow there, so it's
absolute cost, not a competitive gap.

Kept as a regression guard: asserts no scenario full-table-scans a base
table (bare SCAN without USING INDEX).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 15:10:42 +00:00
davotoula 206c0979b1 Code review:
- 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
2026-07-04 15:53:02 +02:00
davotoula 27650f2f77 fix: handle remaining unchecked File.delete() and Tor dir permission results 2026-07-04 15:52:33 +02:00
davotoula 2b9ffb4849 Code review:
- extract shared File.deleteOrWarn helper for cache eviction
2026-07-04 15:51:41 +02:00
davotoula c3d6c05482 fix: handle File.delete() return values in cache eviction and voice cleanup 2026-07-04 15:51:40 +02:00
Claude 87192e7793 fix(store): pin (kind,pubkey) index for multi-author no-limit REQs
The profiles scenario — Filter(kinds=[0], authors=[50]), no limit — was
geode's ~100x loss to strfry on the 1M corpus (99.5ms vs 0.94ms).

Root cause: the REQ path always appends ORDER BY created_at DESC. For a
multi-author pubkey IN(...) filter, query_by_kind_created (kind,
created_at) satisfies that order for free by scanning an ENTIRE kind, so
SQLite prefers it over the selective query_by_kind_pubkey_created (which
would need a sort). The scan is O(all kind-0 profiles) — cheap at 2k, the
99.5ms at 1M. ANALYZE does not fix it (verified: even a reopened store
reading fresh sqlite_stat1 keeps the scan, since the ORDER BY genuinely
lets the scan skip a sort). A single author is costed right and already
seeks; only the IN-list of >1 is mis-costed.

Fix: pin INDEXED BY query_by_kind_pubkey_created for exactly that shape —
multi-author + kinds, no ids, no d-tags, no limit — keeping the ORDER BY.
SQLite seeks the authors and sorts the small result: identical rows,
identical newest-first order (zero behavior change), ~8x at 2k profiles,
growing to ~100x at 1M. Limited feeds (home/global) keep the created_at
scan + early LIMIT; single-author and d-tag queries are untouched. The
index is created unconditionally so the hint never dangles.

(Considered dropping the ORDER BY for no-limit author queries — faster and
hint-free, but it changes on-the-wire result ordering, which broke
FsParityTest's ordered-parity assertions, so it's client-visible. Rejected
in favor of the order-preserving pin.)

Adds ProfilesQueryPlanBenchmark (regression guard: asserts the live REQ
plan seeks the composite index, not the kind scan) and
plans/2026-07-04-profiles-query-plan.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 11:38:31 +00:00
Vitor PamplonaandGitHub 526ffde3f5 Merge pull request #3473 from davotoula/feat/birdstar-detection-kind-2473
support Birdstar bird detection events (kind 2473)
2026-07-04 07:30:25 -04:00
David KasparandGitHub a68572316b Merge pull request #3472 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-04 12:51:34 +02:00
davotoulaandgithub-actions[bot] 370a56ab69 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-04 10:46:51 +00:00
David KasparandGitHub c535c64e67 Merge pull request #3471 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-04 12:44:47 +02:00
davotoula b8d3791ea3 Code review:
- only parse commonName when the alt tag has the Birdstar prefix
- summary() on both Birdstar events now uses the canonical NIP-31
  tags.alt() helper instead of a raw firstTagValue("alt") lookup
- speciesReference() only returns http(s) URLs since UIs render it as
  a clickable link (rejects e.g. javascript: schemes), with a test
- Detection card: parse tags once into a single remember slot, drop
  the near-dead '?: summary' title fallback, stop rebuilding the
  italic TextStyle every recomposition
- Hoist the duplicated bird-emoji literal into a shared BIRD_PREFIX
- Trim the redundant factory test to the assertIs idiom
2026-07-04 12:42:18 +02:00
davotoula a83b8e4064 feat: support Birdstar bird detection events (kind 2473)
- fetch Birdex life lists in home and profile relay REQs
- richer Birdstar cards — common name title, Wikidata link, bird emoji
- surface Birdstar bird detections in home and profile feeds
2026-07-04 10:45:20 +02:00
Claude 9d76a6a8fa perf(store): diagnose the slow profiles query (kind-0 + authors REQ)
profiles (Filter(kinds=[0], authors=[50]), no limit) was the one query
geode lost to strfry on the 1M corpus — 99.5ms vs 0.94ms (~100x).

Root cause: the REQ path appends ORDER BY created_at DESC even with no
limit. query_by_kind_created (kind, created_at) satisfies that order for
free while scanning EVERY kind-0 profile, so SQLite prefers it over the
ideal query_by_kind_pubkey_created (which would need a sort). The scan is
O(all profiles) — cheap at 2k, the 99.5ms at 1M.

ANALYZE does not help: verified that even a reopened store reading fresh
sqlite_stat1 keeps the scan, because the ORDER BY genuinely lets the scan
avoid a sort.

Two fixes measured (both return identical rows), scoped to no-limit
kinds+authors filters:
- Fix A: drop ORDER BY when limit==null -> planner picks the composite
  index itself (~7x at 2k profiles, ~100x at 1M). Changes result order
  across authors (a NIP-01 SHOULD; clients re-sort).
- Fix B: force INDEXED BY query_by_kind_pubkey_created + keep ORDER BY
  (~same speed, newest-first preserved, at the cost of a scoped hint).

Adds ProfilesQueryPlanBenchmark (prints plans+timings, asserts row-count
equivalence) and plans/2026-07-04-profiles-query-plan.md. No production
change yet — the fix is a core QueryBuilder behavior/ordering decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 07:51:01 +00:00
vitorpamplonaandgithub-actions[bot] 3d4c2f1963 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-04 07:31:05 +00:00
Vitor PamplonaandGitHub 9f981117bd Merge pull request #3470 from vitorpamplona/claude/ci-subscribe-before-connect-test-2c5wow
fix(quartz): record REQ state before send in PoolRequests.syncState
2026-07-04 03:28:59 -04:00
Claude f64b2e6f1c fix(quartz): record REQ state before send in PoolRequests.syncState
On connect, syncFilters re-sends every desired REQ through
PoolRequests.syncState. It previously sent the frame and only recorded
the subscription as SENT afterward, in the post-send onSent callback. A
relay that answers faster than that callback runs — the in-process
transport used by the desktop launch-optimization tests, or any relay on
a fast path — can deliver the EOSE while the per-sub state still reads
"nothing in flight" (onConnecting cleared it, onSent hasn't recorded it).
The EOSE handler then sees empty filters, concludes it never sent a REQ,
and fires a duplicate, replaying the whole page a second time.

Pre-mark the sub as SENT under its lock before the frame leaves, mirroring
the decideCommandLocked pre-mark already used by sendToRelayIfChanged, so
a response can never race ahead of the record. The send stays
unconditional: this is a fresh-connection sync (onConnecting always
cleared the per-relay state first), so there is no in-flight REQ on the
new socket to dedupe against.

Fixes the flaky SubscribeBeforeConnectTest, which asserted a pre-connect
subscription delivers exactly its events once and intermittently saw them
doubled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4kmtZoNUSXxwG23wD2JwP
2026-07-04 05:42:46 +00:00
Claude 8a607b08b9 perf(negentropy): profile NIP-77 reconcile, verify prefix-sum fingerprint fix
The 1M relayBench run had geode losing the negentropy phase to strfry
(initial reconcile 6066ms/27r vs 1270ms/14r; identical-set 1947ms vs
557ms). Three layered benchmarks pin where the time actually goes:

- NegentropyReconcileBenchmark (quartz): the kmp-negentropy server loop
  in isolation is ~200ms for the full 14-round exchange — the reconcile
  ALGORITHM is not the bottleneck. (An early version showed 22s/139r;
  that was a benchmark bug — index slices over randomly-sorted ids
  scatter the diff. Real relayBench slices are contiguous time ranges;
  monotonic created_at fixes it and matches strfry's round count.)
- NegentropyServerReconcileBenchmark (geode): the real in-process geode
  server over loopback is 3214ms — 15x the library loop. JFR of the
  server call-trees: ~40% hex/UTF-8/JSON serialization of the payloads,
  ~26% actual reconcile, rest allocation. The gap is the JVM
  constant-factor tax on hex-in-JSON, which strfry pays in C++, not a
  single hotspot.
- NegentropyPrefixFingerprintTest (quartz): the one algorithmic lever.
  Negentropy's fingerprint is an additive sum mod 2^256, so a prefix-sum
  table answers any range in O(1). Proven bit-for-bit identical to the
  library over 2000 random ranges, and 460x faster per call — the fix
  for the ~26% reconcile slice (dominant in the identical-set case).

Not yet wired: the library instantiates FingerprintCalculator
internally, so shipping prefix-sum needs a kmp-negentropy change (or a
quartz-side fast server). Full write-up + artifacts in
quartz/plans/2026-07-04-negentropy-reconcile-profiling.md.

Benchmarks are CI-safe (small defaults / opt-in gates); JFR via
-PnegProfile, scale via -DnegBenchN, geode server bench via
-DnegServerBench=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 05:22:20 +00:00
Claude 8b29c06c13 feat(relayBench): deep resumable --download for million-event corpora
The corpus downloader previously sampled ~100k events max (40-page cap
per kind bucket) and held everything in memory with no failure recovery.
Rework it for full-depth timeline pulls:

- page the latest events newest-first with an inclusive until cursor
  (id-dedup absorbs the same-second overlap) instead of kind buckets
- no page cap: keep paging until the --limit target is met
- stream every unique event to an on-disk NDJSON spill instead of RAM
- checkpoint the pagination cursor per relay; interrupted downloads
  resume where they left off
- reconnect with exponential backoff on socket drops/timeouts
- filter deterministically droppable events (kind-5 deletions are ~40%
  of a live firehose, ephemerals, oversize) at page time so they never
  count toward the download goal

Verified with a 1M-event pull from relay.damus.io (~2.1 GB raw,
4100 pages, ~22 min through a proxy) feeding a full geode vs strfry
run; corpus prepared to exactly 1,000,000 events, fingerprint
141e746599d901f5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EZeWww5TJnzBZKPoc6mvU
2026-07-04 03:52:54 +00:00
Vitor PamplonaandGitHub ba9db9e2cb Merge pull request #3469 from vitorpamplona/claude/relay-performance-geode-quartz-v6lbys
feat(quartz): NostrServer.ingest — local write path with per-submission verify skip
2026-07-03 22:43:49 -04:00
Claude bae2031cf3 fix(geode): validate config knobs and mirror filter at boot
Fail-loud on config that would silently degrade the running relay:

- readers = 0 makes every query hang forever on an empty reader pool;
  readers < 0 crashes with an unrelated message. optimize_interval_seconds
  <= 0 busy-loops PRAGMA optimize under the writer mutex. StaticConfig
  .validate() (called at boot) rejects both.

- A typo in [[mirror]].filter (e.g. `kindss`) parsed to an empty
  match-everything filter through the tolerant deserializer — silently
  widening a trusted upstream's skip-verify scope to the whole firehose.
  MirrorFilterValidator strict-checks the filter JSON at boot: unknown
  keys and non-array list fields fail startup.

- The self-mirror guard now compares scheme-insensitively (ws:// vs
  wss:// for the same host is still us) via displayUrl().

- The maintenance loop rethrows CancellationException and no longer
  swallows Errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:35 +00:00
Claude 401f36ee82 fix(geode): mirror trust bound to origin relay; reconnect advances since watermark
Two mirror hardening fixes from the audit:

- Trust leak: the skip-verify decision was keyed on the subscription id,
  but the client pool dispatches EVENTs by subscription id alone and
  every [[mirror]] upstream shares one client. A hostile untrusted
  upstream could answer with the trusted upstream's subscription id and
  ride its skip-verify into the store. startDown now drops any event
  whose delivering relay isn't the one that subscription dialed
  (relay != up.url). New MirrorWorkerTrustOriginTest injects a foreign
  subId frame from a hostile relay and asserts it never lands.

- Reconnect replay: `since` was frozen at boot, so a long-lived daemon
  re-streamed the whole backfill window on every upstream flap. The down
  path now tracks the newest ingested created_at and, on the
  connected->disconnected edge, advances the REQ's since to
  watermark - overlap before the reconnect re-sends it. Advancing only on
  disconnect means a stable link never re-queries. The reconnect test now
  asserts the watermark advanced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:21 +00:00
Claude 9bb75d9bbd fix(quartz/desktop): close socket on connect failure; NODELAY for desktop pre-init client
TcpNoDelaySocketFactory's connecting overloads used
`socket().apply { connect(...) }`, which leaks the file descriptor if
bind/connect throws (the JDK's connecting Socket constructors close on
failure; ours didn't). Wrapped in a helper that closes on throw. OkHttp
only calls the no-arg overload, so this guards any other direct caller.

DesktopHttpClient's pre-init `simpleClient` (direct relay sockets opened
before setInstance) now gets the same TcpNoDelaySocketFactory as
directClient. failClosedClient is left as-is: it's a SOCKS client and
OkHttp bypasses the socket factory for SOCKS proxies.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:32:08 +00:00
Claude 2be0c9f880 fix(quartz): NostrServer.ingest verifies inline when the queue hook is off
ingest() bypasses the per-connection policy chain, which is where
VerifyPolicy lives. The IngestQueue verify hook only exists when
parallelVerify is true, so with parallelVerify = false and
skipVerify = false, ingest() previously verified nothing — an untrusted
mirror upstream on a relay running the legacy in-policy verify path could
inject forgeries. ingest() now verifies inline in that configuration
(same rejection reason as the queue), so the documented "default keeps
verify-everything semantics" holds regardless of parallelVerify. KDoc
also spells out that ingest() skips the entire policy chain (blacklists,
size limits), which callers must screen for themselves.

Test covers the parallelVerify = false server: forged rejected, trusted
skip and valid still land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:31:54 +00:00
Claude 6ac2903a3a fix(quartz): live negentropy index — same-batch displacement, no-op kind-5, rebuild cap
Audit follow-ups on the live NIP-77 index, all with the store/scan
equivalence test extended to cover them:

- Same-batch replaceable displacement left a dead id in the index.
  applyAfterCommit applied all removes before all adds, so when a later
  row in one transaction displaced an earlier row of the same batch (two
  versions of one replaceable — the mirror-backfill hot path), the
  displaced row's remove no-op'd against an index that hadn't taken its
  add yet, then the add re-inserted it: the index advertised an id the
  trigger had already deleted. recordAccepted now cancels the pending add
  instead of queueing a remove (added is a LinkedHashSet for O(1)
  cancel).

- A kind-5 that deleted nothing (a delete broadcast for events this relay
  never stored — the common case) still invalidated the whole index,
  forcing a full-scan rebuild under the writer mutex on the next
  NEG-OPEN. DeletionRequestModule.insert now returns the rows it deleted;
  recordAccepted only invalidates when that count is > 0, else records the
  kind-5 as a plain row.

- The first NEG-OPEN over a corpus larger than the serve cap scanned the
  whole table uncapped, built a full index that could never produce a
  snapshot, and then maintained it forever for zero benefit.
  liveNegentropySnapshot now caps the rebuild scan at maxEntries + 1 and
  leaves the index unpopulated when the corpus is over-cap (the scan path
  answers NEG-ERR, as before).

- delete/deleteExpired/clearDB invalidate() moved inside the writer mutex
  so no NEG-OPEN can seal a snapshot of just-deleted rows, and no
  concurrent rebuild can be discarded by a late invalidate.

Also corrects the ~40 B/event heap figure to ~140 B (IdAndTime keeps the
id as a 64-char hex string, not 32 bytes) in the strategy/plan docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 02:31:40 +00:00
Claude 3d23f563b1 feat(geode): mirror directions — dir = "down" | "up" | "both" (strfry-router parity)
Each [[mirror]] entry now takes strfry-router's dir:

 - down (default): pull — subscribe to the upstream and ingest, exactly
   as before.
 - up: push — an in-process session on the LOCAL relay subscribes with
   the same scoped filter (so backfill_seconds and filter behave
   identically in both directions, and the relay's own policy chain
   gates what leaves), and every matching event is handed to the
   client's outbox for the upstream, which owns delivery and re-sends
   across reconnects.
 - both: pull and push, with echo suppression: a per-upstream LRU of
   recently exchanged ids keeps an event pulled down from being pushed
   straight back (and vice versa when the upstream fans our own publish
   back). Eviction only costs a duplicate round trip — the stores'
   unique-id constraints stay the correctness backstop.

trusted (verify skip) remains a down-only concept; the up direction
never verifies since the upstream does its own gatekeeping.

Tests: up pushes both the backfill window and the live tail; both
converges two stores with disjoint content and holds exact counts after
the echo settles (no ping-pong). Live-published test events are
genuinely signed — the local publish path verifies, which is also what
the debugging showed: the mechanism was fine, the first version of the
tests was pushing forged events into a verifying relay.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 01:32:18 +00:00
Claude cc73639ae5 docs(geode): SQLite knobs A/B verdict — no winner, knobs stay off by default
Backlog items 4-5 close. Both-variants-in-one-run A/B at 50k, repeated
with relay order reversed: every delta flipped with the order (the
second-running relay won queries and ingest latency in BOTH runs), so
readers=8 / mmap_size=256MiB / temp_store=MEMORY / periodic PRAGMA
optimize are all noise-level on container-class hardware. The config
plumbing stays (hardware-dependent, operators should measure their own
box); the example config now says so explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:50:37 +00:00
Claude 79373672df feat(geode/quartz): [database] tuning knobs + periodic PRAGMA optimize
Backlog items 4-5 plumbing, config-gated and off by default so quartz
library defaults stay untouched for the app-side stores:

 - quartz: SQLiteEventStore/EventStore accept extraPragmas (applied on
   every pooled connection AFTER the built-in configuration, so they
   can override it) and expose optimize() — an analysis_limit-bounded
   PRAGMA optimize for incremental planner-stats refresh.
 - geode: [database] readers / mmap_size / temp_store_memory map onto
   the store; optimize_interval_seconds drives a maintenance coroutine
   (cancelled first in the shutdown hook, before the store closes).

The A/B verdict on whether the example config should RECOMMEND any of
these on container-class hardware follows in the next commit — the
knobs themselves are operator tools worth having either way, since
mmap/temp-store value is hardware-dependent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:48:22 +00:00
Claude fd6662ca30 perf(quartz): TCP_NODELAY for every relay websocket client — kills CLOSE→REQ Nagle stalls
Found while attributing the small-REQ wire floor (backlog item 6,
latency half): geode's new WireReqFloorBenchmark measured a flat
43.7 ms per REQ round trip that survived every server-side change —
store configs, dispatchers, the pump — and then vanished when the
round's preceding CLOSE was dropped. Root cause is client-side: OkHttp
does not set TCP_NODELAY, relays never answer a CLOSE (NIP-01), so its
bytes sit unACKed for the peer's ~40 ms delayed-ACK window and Nagle
holds the next REQ behind them. CLOSE-then-REQ is a Nostr client's
hottest pattern — every feed/filter switch.

relayBench's harness client already shipped a no-delay socket factory
(which is why benchmark numbers never showed the stall) but the
production clients did not. New TcpNoDelaySocketFactory (quartz
jvmAndroid, next to BasicOkHttpWebSocket) is now used by the Android
relay pool factory, the Desktop relay client, amy's relay connections,
and geode's mirror worker. Direct connections only — SOCKS/Tor paths
are untouched.

With the factory, the benchmark puts geode's ~21-row REQ at ~1.25 ms
on the wire (matching relayBench): ~0.6 ms Ktor CIO+OkHttp loopback
floor, ~0.5 ms per-REQ server work (already investigated). Per-frame
burst cost measured negligible and the pump adds ~nothing, so the
send-path latency angle of backlog item 6 is closed as not-a-problem;
its ingest-CPU share remains a separate throughput question. Findings
recorded in quartz/plans/2026-07-04-small-req-floor.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:38:09 +00:00
Claude f22657c870 revert(quartz): inline small-REQ fast path — no wire-level win, floor is transport-side
Reverts the queryRawInline fast path (fb29d655, 1b786f31) per the
keep-only-winners rule. Three relayBench runs at 50k (baseline, cap-256
where the path never engaged, cap-512 where author-archive/by-ids/
500-limit feeds genuinely took it) showed no movement outside the
container drift band — strfry's own numbers drifted ±30% between runs
and inline-eligible scenarios moved the same as ineligible ones.

The in-process win was real but small (~17%, 0.60 -> 0.50 ms per
~21-row REQ); the wire-level p50 is 1.2-1.7 ms, so the missing ~1 ms
per REQ sits in the transport (Ktor frame send path + client round
trip) — backlog item 6 territory, not dispatch. Findings, numbers, and
the do-not-retry note live in quartz/plans/2026-07-04-small-req-floor.md;
SmallReqFloorBenchmark stays as the measurement tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-04 00:03:34 +00:00
Claude 1b786f31b4 perf(quartz): broaden inline REQ eligibility — ids-bounded filters, cap 512
The first cut's rule (every filter needs limit, sum <= 256) missed the
shapes relays actually receive: relayBench's author-archive carries
limit=500 and by-ids carries only an ids list, so the fast path never
engaged in the acceptance run. Bounds now come from limit OR the ids
count (ids are unique keys, the result cannot exceed the list), summed
against a 512 cap — a 500-row inline replay measures single-digit ms,
nothing a CLOSE could meaningfully preempt. Provably unbounded filters
(no limit, no ids — e.g. a bare tag filter) keep the launched path.

Pending: relayBench verdict decides whether the fast path stays at all,
per the keep-only-winners rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:58:48 +00:00
Claude fb29d655fb perf(quartz): inline fast path for bounded small REQs
Backlog item 2 (small-REQ dispatch floor), first cut. relayBench at 50k
shows geode ~2.5x slower than strfry when a REQ returns ~20 events
(author-archive 1.18 vs 0.48 ms EOSE p50) while WINNING the 500-event
scenarios — with tiny results the fixed per-REQ cost dominates. A new
stage benchmark (SmallReqFloorBenchmark) decomposes the floor: at ~21
rows, raw SQL is 0.18 ms and the remaining ~0.42 ms is live-subscription
machinery plus per-REQ coroutine dispatch.

A REQ whose filters all carry a limit summing to <= 256 now runs its
stored replay inline on the receive coroutine and only retains a
live-tail handle (SessionBackend.queryRawInline; LiveEventStore's
queryRaw is re-expressed on the same core) — no per-REQ launch, no Job,
no dispatcher handoffs, no awaitCancellation scaffolding. Unbounded
REQs keep the launched path so CLOSE can always interrupt a giant
replay. In-process time-to-EOSE for ~21-row REQs drops 0.60 -> 0.50 ms;
the bigger effect expected under concurrency (no per-REQ Job+dispatch
churn) is relayBench's to judge.

InlineReqFastPathTest pins wire-behavior parity: stored-then-EOSE
ordering, live tail delivery and CLOSE detachment, same-subId
replacement, launched fallback for unbounded and over-cap REQs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:54:19 +00:00
Claude 0d6a9b1e4d docs(quartz): record strfry head-to-head for the live negentropy index
Same container, 50k corpus, geode --no-search, strfry built from
source: identical-set reconcile 41.4 ms (geode) vs 30.1 ms (strfry) —
down to ~1.4x from the campaign-opening full-scan-per-open; cold
reconcile 177 vs 112 ms (geode's first open pays the lazy rebuild);
ingest at parity in this container; storage 72.8 vs 106 MiB. Notes the
zero-copy IStorage follow-up that would close the remaining ~11 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:15:09 +00:00
Claude de388907c2 docs(quartz): record live-negentropy-index A/B results — shipped
Two relayBench runs (50k corpus, both geode variants side by side, then
order-reversed): identical-set reconcile 56/57 ms with the index vs
110/130 ms without in both orders (~2.2x, the post-write NEG-OPEN the
old cache always missed); ingest and first-ever reconcile deltas flip
with relay order, i.e. run-order noise — no regression. Also records
the run-order-bias protocol note for future A/Bs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:09:56 +00:00
Claude 73ee5cce99 perf(quartz/geode): serve full-set NEG-OPENs from the live negentropy index
Milestones 2-3 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.

SQLiteEventStore now maintains the LiveNegentropyIndex when the strategy
opts in (geode's does by default; [negentropy].live_index = false turns
it off; app-side stores are untouched):

 - Write paths collect a LiveIndexDelta and apply it after COMMIT while
   still holding the writer mutex — rolled-back savepoint rows never
   reach the index and updates land in exact commit order (also vs the
   rebuild, which runs under the same mutex).
 - Replaceable/addressable overwrites report the row their BEFORE-INSERT
   trigger displaces via one indexed pre-SELECT that mirrors the trigger
   predicate (including the NIP-01 lowest-id tie-break and the
   d_tag-NULL case).
 - Paths that can't itemize (kind-5, vanish, delete-by-filter/id,
   expiration sweeps, clearDB) invalidate; the next NEG-OPEN rebuilds
   from one scan on the writer connection.
 - Until that first NEG-OPEN populates the index, ingest pays zero
   bookkeeping — the populated check happens under the writer mutex so
   it can't race the rebuild.

LiveEventStore serves a single unconstrained filter (the relay-relay
sync default; relayBench sends exactly this) from the index; everything
else keeps the scan+seal path and its single-slot cache.

Micro-benchmark at 50k events (LiveNegentropyBenchmark, in-container):
scan+seal cold path 80-100 ms; index post-write open 9-16 ms (~5-10x).
The relayBench A/B is the acceptance gate and comes next.

Correctness: LiveNegentropyIndexStoreTest asserts index content ==
snapshotIdsForNegentropy scan after every mutation pattern (overwrites,
losers, kind-5 rebuilds, filter deletes, mixed-outcome batches,
transactions); the full geode suite (NIP-77 + interop sync tests) runs
with the index on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 23:05:23 +00:00
Claude 7ad7dee5fa feat(quartz): LiveNegentropyIndex — always-current (created_at, id) set for NIP-77
Milestone 1 of quartz/plans/2026-07-03-incremental-negentropy-storage.md.

Sorted array with binary-search insert (near-tail in the common case),
itemized remove for displaced rows, wholesale invalidate for delete
paths that can't itemize, and sealed snapshots memoized per mutation
generation — reconcile only reads, so one snapshot backs any number of
concurrent sessions and stays immutable under later writes. Over-cap
answers null so the caller keeps the strfry-parity NEG-ERR.

Not wired into any store yet; next milestones plumb displaced-row
deltas from the SQLite modules and serve index-total filters from it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:41:35 +00:00
Claude 175cac3e46 docs(quartz): plan — incremental live negentropy storage
Design for backlog item 3 of the relay performance campaign: an
always-current (created_at, id) index maintained from the store's write
path so cold NEG-OPENs stop paying the full scan + O(n log n) seal
(~340 ms at 50k events vs strfry's ~21 ms off its live tree). Covers
the snapshot/COW model, the removal-correctness split (RETURNING deltas
for replaceable overwrites, wholesale invalidation for rare delete
paths), IndexingStrategy gating so app-side stores are untouched, and
the micro + relayBench A/B measurement plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:36:06 +00:00
Claude c2cabf3c47 feat(geode): mirror survives upstream restarts — retry pump, ping keepalive, e2e proof
Measured over the real OkHttp transport (upstream killed mid-mirror and
restarted on the same port): NostrClient re-dials once on disconnect and
then falls back to its 60s keep-alive, which showed up as a 61s mirror
blackout when the immediate re-dial raced the port rebind.

 - Retry pump: the worker nudges the pool every 5s. Cheap and safe —
   reconnectIfNeedsTo skips connected relays and each relay's
   exponential backoff (1s doubling, 5min cap) still gates real dial
   attempts, so dead upstreams aren't hammered. Restart recovery drops
   from 61s to ~6s in the new reconnect test.
 - WebSocket pings (120s, same as the Android relay pool): without
   them a half-open connection (network drop, no FIN) never fires
   onDisconnected and the mirror would stall silently forever.
 - MirrorWorkerReconnectTest: end-to-end over a real Ktor port — ride
   through the drop, re-subscribe, drop the duplicate replay, pull the
   event that only exists on the new instance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 22:26:15 +00:00
Claude a2b38cd1a3 feat(geode): per-upstream [[mirror]] filters, strfry-router parity
Each [[mirror]] entry takes an optional NIP-01 filter as a JSON object
string, like strfry-router's per-stream filter. It is applied twice,
matching strfry's design (cmd_router.cpp):

 - it shapes the REQ sent upstream (the mirror still owns since via
   backfill_seconds and strips limit — the subscription is unbounded);
 - every delivered event is re-checked against it before ingest, so an
   upstream answering outside its REQ — including a trusted one whose
   events skip signature verification — can only inject events inside
   the operator-declared scope. Out-of-scope deliveries surface on a
   'filtered' counter.

Malformed filter JSON fails the boot, not the first delivery. Several
disjoint scopes for one upstream = repeat [[mirror]] with the same url.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:54:49 +00:00
Claude 50e259b495 feat(geode): [[mirror]] upstream streaming with relay-to-relay trust
Backlog item 1 of the relay performance campaign: skip signature
verification for events ingested from explicitly configured trusted
upstream relays, strfry-router style.

geode now dials each [[mirror]] url from the config, subscribes to
everything newer than now - backfill_seconds, and feeds the stream
through NostrServer.ingest (same group-commit writer + live fanout as
client publishes). The NostrClient underneath owns reconnects, backoff
and REQ re-sync; duplicate replays after a reconnect are rejected by
the store's unique-id constraint and only surface as counters.

trusted = true is the per-upstream trust switch: events from that
connection skip Schnorr verify. The trusted identity is the URL this
relay dialed (TLS-authenticated for wss://), never anything an inbound
peer claims. Default is false — mirror-but-verify — and a relay with no
[[mirror]] entries behaves exactly as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:22:46 +00:00
Claude 32b15d55d0 feat(quartz): NostrServer.ingest — local write path with per-submission verify skip
Adds a public local-ingest entry to NostrServer for events that don't
arrive over a client connection (mirror/sync workers, import jobs). It
routes through the same group-commit IngestQueue and live fanout as a
client publish, and each Submission can opt out of the parallel
Schnorr-verify hook — the relay-to-relay trust model, for events
streamed from an upstream that already verified them (verify profiles
at ~8% of busy ingest CPU).

Library defaults are unchanged: skipVerify defaults to false everywhere
and nothing in the client-publish path can set it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TtDNpayEYvJH7QuPswND3A
2026-07-03 21:22:31 +00:00
Vitor PamplonaandGitHub 4e895c20dd Merge pull request #3468 from vitorpamplona/claude/dedup-decode-benchmark-ci-rl7v8u
Make dedup decode benchmark resilient to CI load variance
2026-07-03 17:21:52 -04:00
Claude 130135250b fix: deflake DedupDecodeBenchmark timing assertion on loaded CI runners
A single-sample wall-clock speedup assertion (>1.5x) flakes when the
shared CI machine is loaded. Apply the same pattern already used by
ParallelVerifyBenchmark: retry up to 3 measurement attempts taking the
best, keep a hard 1.05x floor that a real regression (~1.0x when dedup
saves no work) can never pass, and warn instead of fail in the noise
band. The deterministic parsed/reused-count correctness gate still
fails hard on every attempt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SzMix4ZivxoYrcn88TMqtF
2026-07-03 21:18:32 +00:00
David KasparandGitHub ae5e0ff8ed Merge pull request #3467 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 22:27:50 +02:00
vitorpamplonaandgithub-actions[bot] e2ab7ffd6a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 19:59:42 +00:00
Vitor PamplonaandGitHub 55698947e4 Merge pull request #3466 from vitorpamplona/claude/relay-benchmark-strfry-geode-4v2wxq
Add relayBench: comprehensive Nostr relay benchmark suite
2026-07-03 15:57:40 -04:00
Claude 6760ba450c fix: FTS catch-up worker must not leak an uncaught exception at shutdown
The worker's writer-mutex fast path can slip past its own scope
cancellation and run one batch against a store that close() already
freed. The resulting closed-connection exception escaped scope.launch
under a SupervisorJob with no handler — harmless in production, but the
kotlinx-coroutines-test global handler attributes it to whatever runTest
starts next (seen on CI as EventSourceServerTest.countUsesSource failing
with UncaughtExceptionsBeforeTest). Catch, log, and stop the pass:
nothing depends on it — the pre-search drain covers search correctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 19:22:43 +00:00
David KasparandGitHub c81fc8cb02 Merge pull request #3465 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 21:00:22 +02:00
Claude 35449c7437 fix: reclaim pool connections before close; strip NIP-50 extensions in queryRaw
Two issues surfaced by rebasing onto main:

- SQLiteConnectionPool.close() freed the writer's native handle without
  taking the writer mutex, so a block still running on another thread
  (the deferred-FTS catch-up worker's current batch outlives its scope
  cancellation) could call sqlite3_prepare on a freed sqlite3* — native
  heap corruption, SIGSEGV in sqlite3DbMallocRawNN (reproduced twice in
  :geode:test). close() now reclaims every reader from the channel and
  acquires the writer mutex before closing handles, then releases so
  stragglers get the managed closed-connection exception. Idempotent
  via a closed flag.

- main's NIP-50 fix (7deda28d) strips search-extension tokens before
  every store query, but the zero-decode queryRaw replay path added on
  this branch predates it and passed raw filters through — an
  extensions-only search would hit SQLite FTS as column syntax and
  error. queryRaw now strips like query/count/snapshot do.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:51:38 +00:00
vitorpamplonaandgithub-actions[bot] 58a72c02fa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 18:39:52 +00:00
Claude 115963eb1c perf: defer NIP-50 tokenization off the insert path
FTS indexing ran inside every insert's transaction — a measurable slice
of write cost (relayBench: ~18% of ingest throughput) paid at publish
time for a feature only search queries read. It now runs as a watermark
catch-up:

- IndexingStrategy.deferFullTextSearchIndexing (default false; geode's
  relay strategy enables it with search). Deferred inserts skip
  tokenization entirely.
- FullTextSearchModule keeps a fts_catchup_state watermark (everything
  <= last_row_id is indexed) and gains catchUpBatch(): scan past the
  watermark, tokenize, advance — one write transaction per batch, so
  publishes interleave. DATABASE_VERSION 3->4 seeds the watermark at
  MAX(row_id) for existing (synchronously indexed) databases.
- NostrServer runs the catch-up worker, poked by IngestQueue's new
  onBatchCommitted hook, and *yields to publish traffic*: it only
  drains while the queue has no backlog (IngestQueue.hasBacklog()), so
  bursts ingest at no-FTS speed and tokenization fills the gaps.
- LiveEventStore drains the backlog synchronously before serving any
  filter with a search term (query, queryRaw, count) — NIP-50 results
  stay exactly as fresh as the synchronous path; the deferral is
  invisible to correctness. Geode's existing search tests pass
  unchanged through this path.

The first implementation reused reindexBatch and collapsed ingest 8x —
its per-row 'DELETE FROM event_fts WHERE event_header_row_id = ?'
matches on a plain FTS5 column, i.e. a full FTS-table scan per row
(O(n²) overall), and the worker competed with the replay for the writer
mutex. catchUpBatch therefore inserts without the delete (rows past the
watermark are never indexed; switching a DB between deferred and
synchronous strategies requires reindexAll, same rule as a
searchable-kinds change), and the worker backs off whenever publishes
are pending.

Alternating A/B, 50k corpus, search-enabled default: 4,902/5,090/5,136
events/s synchronous vs 5,425/5,611 deferred (+8-12%), approaching the
--no-search ceiling while keeping NIP-50 advertised and fresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:39:13 +00:00
Claude f2174bdab3 perf: cache the sealed negentropy snapshot across NEG-OPENs
A NIP-77 server session rebuilt its reconciliation structure from
scratch on every NEG-OPEN: full id+created_at scan, per-entry hex
decode into a fresh StorageVector, O(n log n) seal. That cost grows
with the corpus and is paid even when nothing changed — the exact
shape of a periodic mirror's heartbeat, where N peers reconcile the
same broad filter over and over. relayBench measured 342 ms per
identical-set reconcile at 50k events vs strfry's 26 ms off its
always-current LMDB tree.

Reconciliation only *reads* the sealed storage, so one instance can
back any number of concurrent sessions:

- NegentropyServerSession now accepts a pre-sealed IStorage (the
  List<IdAndTime> constructor remains and delegates).
- SessionBackend.sealedNegentropyStorage() builds + seals (null when
  the set exceeds maxSyncEvents); LiveEventStore overrides it with a
  single-slot cache keyed by (filter set, write generation) plus a 30s
  TTL. The generation bumps on every accepted ingest; the TTL bounds
  staleness from delete paths the counter can't see (expiration
  sweeps, admin purges) — negentropy snapshots are point-in-time sets,
  so seconds of staleness only means a peer briefly re-offers ids.
- NegSessionRegistry.open consumes the shared sealed storage;
  over-cap NEG-ERR behavior unchanged (strfry parity).

relayBench gains a 'heartbeat' measurement — the identical-set
reconcile repeated immediately with no writes in between. At 50k
events: geode 342 ms -> 27.8 ms vs strfry 21.1 ms (near parity; was
13x). Cold reconciles (first open after a write) are unchanged.

Also fixes the GeodeVsStrfryNegentropySyncTest fixture to write
'nofiles = 0' so the opt-in interop test can boot strfry inside
containers with a low RLIMIT_NOFILE hard cap; the interop suite passes
against strfry v1-b80cda3 with the cache in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:53 +00:00
Vitor PamplonaandGitHub 51d5d382c5 Merge pull request #3462 from davotoula/l10n/prune-dead-locales
Prune empty locale files and dead language-picker entries
2026-07-03 14:37:41 -04:00
Claude 3c6d36cde1 feat: authors-only query index — close the one gap vs strfry's index set
Reviewed strfry's LMDB indices (golpe.yaml) against geode's SQLite set:
the two are nearly isomorphic — time, id, kind+time, author+kind+time,
tag+time, plus conditional deletion/expiration/replaceable entries
(geode's are partial indexes, so ordinary events don't pay for them).
Nothing to drop. One real hole: strfry maintains a plain
pubkey(+created_at) index and geode had none, so an authors-only filter
(no kinds) — archive pulls, account-migration tools, 'everything by
these pubkeys' — degraded to a full walk of the time index. EXPLAIN
confirmed: SCAN query_by_created_at_id.

- quartz: IndexingStrategy.indexEventsByPubkeyAlone (default false —
  clients query their supported kinds and can skip it) gates a new
  query_by_pubkey_created index; DATABASE_VERSION 2→3 with an
  idempotent migration that backfills it for opted-in strategies.
- geode: relayIndexingStrategy turns it on.
- relayBench: new 'author-archive' scenario — every kind by 3 *quiet*
  pubkeys. Quiet is the point: prolific authors are dense in the time
  index and a scan finds them quickly, which is why the suite never
  caught this; sparse authors force the full walk.

Measured (50k corpus): author-archive EOSE p50 42.8 ms -> 3.6 ms (12x,
and the old path grows linearly with table size); ingest 5,337 -> 5,156
events/s (~3%, the one extra B-tree per event). strfry reference on the
same scenario: 0.52 ms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:40 +00:00
Claude f672860ad9 perf: pipeline verify and write stages in IngestQueue
The drain loop ran verify(batch N) and insert(batch N) strictly
back-to-back: the SQLite writer idled during every Schnorr verify and
the CPU cores idled during every commit. Split it into two stage
coroutines joined by a capacity-1 channel — a verifier that collects
and checks batch N+1 while the writer commits batch N. Same shape as
strfry's ingester/writer thread split.

Ordering (single verifier, single writer, FIFO handoff), OK-after-commit
semantics, per-row error isolation and submit() backpressure are all
unchanged; total in-flight grows by at most one batch.

Alternating A/B on the 10k corpus, 4-core host with the benchmark client
competing for the same cores: sequential 4,111/3,977/4,443 vs pipelined
4,050/5,020/5,092 events/s (~+13% mean). The overlap should widen on
dedicated relay hosts where verify has its own cores. Feature-parity
run after this change: geode --no-search 5,911 vs strfry 9,374 events/s
(1.59x, down from 2.2x at the start of the perf work).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:40 +00:00
Claude 58c580cd34 feat(geode): --no-search / [options].full_text_search — run without NIP-50
strfry implements no NIP-50 at all, so geode's default setup pays FTS
tokenization on every searchable event for a feature the other side of a
benchmark isn't providing. The new switch removes it cleanly:

- geode: --no-search CLI flag and [options].full_text_search TOML key
  (default true). When off, the store skips the FTS index entirely,
  NIP-11 stops advertising 50 (an explicit [info] nips list stays
  operator-authoritative), and search filters match nothing via the
  existing QueryBuilder guard. RelayIndexingStrategy becomes
  relayIndexingStrategy(fullTextSearch) with the stock val kept.

- relayBench: --geode-no-search runs the geode entry with the flag (relay
  named geode-nosearch in reports); README documents the apples-to-apples
  rationale and a recipe for benchmarking both geode flavors side by side.

Alternating A/B on the 10k corpus: 4,767/4,792 ev/s without search vs
4,111/3,977 with — NIP-50 costs ~18% of ingest throughput at current
write-path speed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude 001d5f0eb7 fix: port the O(n²) replay-dedup fix into queryRaw; adopt IdAndTime negentropy API
Main's giant-REQ fix (18bd3600) replaced query()'s copy-on-add immutable
dedupe set with a spin-locked HashSet, but the rebase left queryRaw —
now the default REQ path — on the old pattern, which would have
reintroduced the O(n²) crawl for large replays. Both paths now share
the mutable-set-under-spinlock shape.

relayBench's sync driver moves to NegentropySession.fromEvents(),
following the session's new List<IdAndTime> constructor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude 5f0a629e18 perf: relay read/write path — ordering index, zero-decode REQ replay, prepared-statement cache
Three changes, each validated head-to-head against strfry with relayBench
(same 10k-event corpus a1cd3517a8296911, stock configs, sig verify on):

- geode: RelayIndexingStrategy turns on indexEventsByCreatedAtAlone for
  the relay's stores (quartz's DefaultIndexingStrategy stays off for
  client-side stores). A relay can't predict client filters, and the
  cheapest REQ of all — {"limit":N} — was a full-table scan + top-N
  sort: 40 ms and O(table) growth before; 11 ms and index-streamed
  (first event 30 ms -> 1.9 ms) after.

- quartz: zero-decode REQ replay. Stored events now stream as RawEvent
  (tags kept in serialized form) and are spliced directly into wire
  frames — no tags parse, no EventFactory dispatch, no re-serialize per
  row. Gated on the new IRelayPolicy.filtersOutgoingEvents capability:
  policies that can veto per-event delivery (none today) keep the
  materialized path; everyone else skips it. Live post-EOSE delivery is
  unchanged (live matching needs Event objects).

- quartz: StatementCachingConnection wraps the pool's writer and reader
  connections, replaying prepared statements instead of re-preparing per
  event/REQ (eager reset on return keeps cursors from holding table
  locks; a 256-statement cap bounds client-controlled filter-shape
  variety). Ingest went 3,000 -> 4,700 events/s (+55%) — prepare
  overhead was the single largest non-crypto write cost.

Net effect on the benchmark: ingest gap vs strfry narrowed from 2.2x to
1.8x, the firehose latency gap from 4x to 1.2x, and geode now wins 4 of
9 query-latency scenarios (notifications, hashtag, by-ids,
recent-window) plus most concurrent-throughput scenarios, while keeping
the smaller on-disk footprint. Result sets stayed byte-identical across
relays and NIP-77 sync still converges.

Measured but deliberately NOT taken: per-row SAVEPOINT elision (+4%,
within run noise — not worth weakening batch error isolation), FTS-off
(+25% ingest but drops NIP-50), --no-verify (+45% but unfair/unsafe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:39 +00:00
Claude 5f3a790d56 feat: add relayBench — head-to-head relay benchmark (geode vs strfry vs any)
New :relayBench module that boots relay implementations as real external
processes under equivalent setups (persistent storage, sig verification on,
no auth) and compares them on the same corpus:

- Ingest: receipt->queryable-by-REQ latency (publish on one connection,
  hammer-poll REQ{ids} on another), OK-ack latency, and pipelined corpus
  replay throughput over N connections.
- Queries: client-realistic filters derived from the corpus (home feed,
  thread, notifications, profiles, hashtag, by-ids, ...), time-to-EOSE
  percentiles plus aggregate events/sec under concurrency; result-set
  counts are cross-checked between relays and mismatches flagged.
- NIP-77 negentropy sync between every relay pair: 80%/80% slices with 60%
  overlap, reconcile timing/rounds/wire-bytes per server side, delta
  transfer, steady-state identical-set reconcile, convergence verified.
- Storage footprint after full ingest.

Corpora (all cached as NDJSON + manifest with a sha256 id fingerprint so
results are comparable across runs and machines):
- synthetic (default): deterministic to the byte — seeded keys, fixed
  timestamps, seed-derived BIP-340 aux nonces — with a realistic social
  shape (zipf authors, threads, reactions, reposts, zap request/receipt
  pairs, hashtags);
- the checked-in real dump (quartz test fixture, ~31k unique 2024 events);
- external dumps (NDJSON or JSON array, gzip sniffed by magic bytes),
  e.g. the 2.1M contact-list archive, with --max-event-bytes/--max-tags
  raising both the corpus filter and the strfry config together;
- live download from public relays.

Every source runs through the same preparation: dedup, drop unsigned/
ephemeral/kind-5, enforce relay ingest caps, parallel Schnorr verify,
chronological sort.

relayBench/run.sh is the one-command entry point: builds geode + harness,
resolves strfry (STRFRY_BIN, PATH, or source build into .cache), runs the
suite and renders an ANSI report with per-metric bars and winners, plus
report.md and results.json under relayBench/results/<timestamp>/.

The harness client disables Nagle (TCP_NODELAY): with the JDK default, a
REQ following the previous round's CLOSE stalls a full delayed-ACK
interval and every latency floors at ~44 ms against both geode and strfry
(verified: ~0.3 ms with it off).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NeoCvXnTxsKzqurkmjdC46
2026-07-03 18:37:16 +00:00
Vitor PamplonaandGitHub acaf768309 Merge pull request #3464 from vitorpamplona/claude/nip50-eventstore-search-gv9ses
Strip NIP-50 search extensions before querying SQLite FTS
2026-07-03 14:25:27 -04:00
Claude 7deda28d29 fix(quartz): strip NIP-50 extension tokens before FTS MATCH in the relay store path
Raw key:value tokens like include:spam reached SQLite FTS MATCH, where
the colon is column-filter syntax — any REQ carrying an extension token
died with CLOSED "no such column: include" instead of matching.

Adds SearchQuery.stripExtensions() plus Filter/List<Filter>
.strippingSearchExtensions() so EventStore users can drop the tokens
before querying, and applies them in LiveEventStore (query, count,
negentropy snapshots). Per NIP-50, unsupported extensions are ignored:
an extensions-only search becomes unconstrained, not match-nothing.
EventSource-backed search relays still receive the raw string since a
real search backend wants the extensions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tujoyfc2kNLZNVgiLZAR7F
2026-07-03 18:09:37 +00:00
David KasparandGitHub 31e9d0f14c Merge pull request #3463 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 20:09:03 +02:00
davotoulaandgithub-actions[bot] 5e6405e3c9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 18:08:00 +00:00
davotoulaandClaude Fable 5 d9fda175e7 fix(l10n): repair mojibake in three Tamil strings
The 2026-06-25 translation import left U+FFFD replacement characters
in show_npub_as_a_qr_code, show_nprofile_as_a_qr_code and relay_reorder
in both values-ta and values-ta-rIN. Restore the accusative suffix
as npub-ஐ (independent vowel AI), matching the nsec-ஐ
precedent in the same files. Clears Sonar's file-encoding warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017iWg6SxLXteav6xJJz7gKS
2026-07-03 20:04:54 +02:00
davotoulaandClaude Fable 5 b3f98840a6 chore(l10n): prune empty locale files and dead language-picker entries
Delete 19 strings.xml files that contain zero string resources:

- 12 exports of Crowdin targets with 0% translation (ca-rES, cy-rGB, da-rDK,
  gu-rIN, hr-rHR, iw-rIL, kk-rKZ, ks-rIN, lt-rLT, ne-rNP, sa-rIN, pcm-rNG).
  NOTE: the next Crowdin sync recreates these unless the corresponding target
  languages (Catalan, Welsh, Danish, Gujarati, Croatian, Hebrew, Kazakh,
  Kashmiri, Lithuanian, Nepali, Sanskrit, Nigerian Pidgin) are unchecked in
  the Crowdin project settings; they hold zero translations there, so
  unchecking loses nothing and a language can be re-enabled when a translator
  volunteers.
- 6 orphans with no Crowdin target at all (et-rEE, fo-rFO, ku-rTR, so-rSO,
  ss-rZA, ur-rIN).
- values-hu, emptied earlier to fix aapt2 duplicate resources; the real
  Hungarian translation lives in values-hu-rHU (until Hungarian is
  consolidated to base like Czech in #3461).

Also drop the matching locales_config entries so the per-app language picker
stops offering languages that render 100% in English, including the dangling
"ar" entry (values-ar has never existed; ar-SA remains) and the duplicate
"hu" entry (hu-HU remains).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 19:03:47 +02:00
davotoulaandClaude Fable 5 ab7abd9614 docs(skills): point find-missing-translations at the base Czech locale
Czech was consolidated onto values-cs in PR #3461 (values-cs-rCZ deleted,
cs: cs languages_mapping). Update the reference-locale table, diff commands,
and prose accordingly, and note that the remaining locales keep their
region-qualified dirs until consolidated the same way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 18:40:09 +02:00
Vitor PamplonaandGitHub 72181a1a05 Merge pull request #3461 from davotoula/l10n/czech-base
Consolidate Czech onto the base values-cs qualifier
2026-07-03 12:28:00 -04:00
davotoulaandClaude Fable 5 916ac267af fix(l10n): consolidate Czech onto the base values-cs qualifier
Czech has a single Crowdin target (cs), but Crowdin's default android_code
for it is cs-rCZ, so the human/Crowdin translation was written to
values-cs-rCZ. Promote it to base values-cs, delete the regional dir, map
cs -> cs in crowdin.yml so future syncs land at the base (single target =>
no collision), and drop cs-CZ from locales_config so the per-app language
picker shows just "Czech".

Also removes the bulk-filled base values-cs from PR #3371 ("Add and update
translations across 50+ locales"), which had translated 38 strings marked
translatable="false" in the source (the *_search_keywords English index,
github/mastodon proper nouns, notification-channel names). The promoted
cs-rCZ is Crowdin-clean (0 translatable="false" leakage), so this also
fixes that contamination for Czech.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KhEN93aKyQCFVpLUgRNXid
2026-07-03 17:01:33 +02:00
David KasparandGitHub 81a0444929 Merge pull request #3460 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 16:42:30 +02:00
vitorpamplonaandgithub-actions[bot] 04fae97d10 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 14:41:28 +00:00
David KasparandGitHub 51bc2ffcc4 Merge pull request #3456 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-03 16:41:02 +02:00
vitorpamplonaandgithub-actions[bot] 9eb447de38 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-03 14:39:19 +00:00
Vitor PamplonaandGitHub 1460412989 Merge pull request #3458 from vitorpamplona/claude/nostrclient-receiver-perf-d8u27o
Add production benchmarks and negentropy sync optimizations
2026-07-03 10:39:12 -04:00
Claude b5fa6b20c1 feat(cli): migrate amy sync onto the windowed negentropyReconcile pipeline
Replaces the hand-rolled raw-WebSocket NIP-77 negotiate loop (single
un-windowed session — a strfry max_sync_events overflow was a hard
error) with quartz's negentropyReconcile: created_at window splitting
on overflow, keep-alive connection pinning, and streaming id batches.
Downloads and uploads now pipeline with the remaining reconcile
rounds: need-id batches feed 4 concurrent by-id drains, have-ids feed
an uploader (peak 7 subscriptions, under the common relay cap of 20).
Every downloaded event still funnels through the verify-and-store
path. Output field 'rounds' (protocol round-trips) is now 'windows'
(created_at splits).

Verified end-to-end against embedded geode relays: down-only 25/25,
up-only 5/5, and bidirectional re-runs converge to a zero diff.

Also records both adoptions in the perf plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:37:25 +00:00
Claude f699fac16c perf: adopt CachingEventDecoder in Android, Desktop, and amy clients
Passes decoder = CachingEventDecoder() at all four NostrClient
construction sites: the Android app pool (AppModules), the Android
crawl client (buildCrawlClient — Event Sync / Cashu discovery, the
duplicate-heaviest path), the desktop RelayConnectionManager, and
amy's Context. Duplicate EVENT frames (14-57% of production traffic)
now skip the full JSON re-parse; dispatch semantics unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:37:25 +00:00
Vitor PamplonaandGitHub c90c1b4768 Merge pull request #3459 from vitorpamplona/claude/dispatchers-thread-caps-s8yp4c
Add lock-free concurrent collections and fix UDP socket threading
2026-07-03 10:37:00 -04:00
Claude b561d2b7e6 docs: record lock vs lock-free decoder A/B — 3-4.5x under concurrent decode
Same-JVM interleaved comparison against the resurrected spin-lock
implementation: single-threaded parity (the uncontended lock was ~free),
but 8 threads sharing one decoder run 3.0-4.5x faster lock-free — the
spin lock serialized the concurrent hit path just like the old global
PoolRequests lock did.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:09:06 +00:00
Claude aa4f965b28 test: make ParallelVerifyBenchmark tolerant of suite load
The speedup assertion depends on AVAILABLE parallelism, which a
full-suite run (the pre-push hook) can eat — it flaked at 1.14x under
load. Now retries up to 3 measurement passes and enforces a hard 1.05x
floor that a genuine serialization regression (the per-event-async
version measured 0.94x) can never pass, warning instead of failing in
the noise band between floor and the 1.5x target.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 14:00:54 +00:00
Claude 72c09649a6 perf: make CachingEventDecoder lock-free via ConcurrentHashCache expect/actual
Replaces the decoder's spin lock with lock-free concurrent maps so the
hot per-frame duplicate check never serializes across the pool's relay
consumer coroutines. New minimal ConcurrentHashCache expect/actual
(get/put/size/clear) following LargeCache's per-platform choices:
ConcurrentHashMap on JVM/Android, CacheMap on Apple, copy-on-write on
the CI-only Linux target. Counters become atomics; the generational
rotation keeps its deliberately-tolerated benign races, now documented
per failure mode (each is at worst a redundant re-parse, never a wrong
message).

CachingEventDecoderConcurrencyTest hammers one shared decoder from 8
threads with 80k duplicate-heavy frames and capacity 256 (rotations
fire constantly): zero wrong messages, exact parsed+reused accounting.
The 7 scan-safety tests and DedupDecodeBenchmark's enforced speedup
pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:49:26 +00:00
Claude 27f6bf6970 docs: ParallelEventVerifier is for bulk flows — app verify is already parallel across relays
Code review confirmed each relay connection owns its own consumer
coroutine on Dispatchers.IO with no downstream funnel or shared lock
before justVerify (LargeCache is a ConcurrentSkipListMap; the
PoolRequests lock is per-subscription now), so multi-relay bursts
already verify in parallel across cores. Corrects the earlier follow-up
suggesting a CacheClientConnector integration: the accessory's scope is
single-connection bulk streams, plus a possible future dispatcher-
hygiene fix if on-device profiling shows CPU-bound verifies
oversubscribing the 64-thread IO pool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:33:47 +00:00
Claude d271223521 revert: restore unbounded websocket receive channels
Deliberate design decision reversing the 4096-frame receive bound:

1. The remote infrastructure isn't ours — TCP backpressure parks the
   backlog in the RELAY's outbound buffers. A client should release the
   relay from its duties as fast as it can send and own the buffering
   itself.
2. The app holds 2000+ simultaneous relay connections; a bounded buffer
   under a slow consumer blocks OkHttp reader threads, and at that
   connection count blocked readers are a thread-starvation hazard far
   worse than the heap growth they prevent.

The UNLIMITED channels now carry an explicit do-not-bound comment with
this rationale, and the slow-consumer risk is addressed from the other
side: keep the consumer faster than any relay's send rate
(CachingEventDecoder, ParallelEventVerifier, PoolRequests sharding).
BoundedReceiveBufferTest removed with the bound it tested; plan doc
records the decision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 13:02:10 +00:00
Vitor PamplonaandGitHub 28412c055c Merge pull request #3457 from nrobi144/feat/desktop-notifications
feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
2026-07-03 08:44:23 -04:00
nrobi144andClaude ecedc4affe feat(desktop): notifications redesign — inbox UX, native OS toasts, shared filter
Rework the Amethyst Desktop notification experience end-to-end.

**In-app inbox** (`desktopApp/…/ui/NotificationsScreen.kt`)
- Dedicated Notifications entry in the sidebar and a new
  `DeckColumnType.NotificationSettings` overlay reachable from a ⚙ button
  in the column header — back button renders automatically via
  `navState.hasBackStack` in deck mode and via body Back in single-pane.
- Redesigned column: filter tabs (All / Mentions / Replies / Reactions /
  Zaps / Reposts / DMs) with per-kind counts, grouped cards (reactions
  and reposts collapse to "N reactions on your post" per day), unread
  dots driven by a persisted `lastReadAt` per pubkey, freshest-first
  ordering via `compareByDescending { timestamp }`.
- User metadata: avatars + display names on every row (including reactor
  strip inside grouped cards) resolved from `LocalCache`, with
  `metadataVersion` observation. Zap sender is the actual zapper (via
  `NotificationItem.effectiveAuthorPubKey` unwrapping
  `LnZapEvent.zapRequest.pubKey`), not the LNURL provider.
- Reaction/repost group cards are clickable → thread; DM cards click →
  Messages column; expandable to show note preview + reactor list.
- `NotificationSettingsScreen`: master toggle, 7 per-kind toggles,
  manual-DND dropdown, preview-privacy switch, per-platform status
  card, "Send a test toast". Permission-aware button adapts across
  NotRequested → Granted / Denied / BundleRequired with a macOS System
  Settings deep-link. State syncs with OS-level changes via
  `LocalWindowInfo.isWindowFocused` regain refresh.

**Native OS notifications** (`commons/…/moderation/notifications/`)
- `NotificationDispatcher` interface + `PermissionState` sealed
  hierarchy in `commonMain`. JVM impl `NucleusNotificationDispatcher`
  routes through Nucleus (three per-OS artifacts: macOS
  `UNUserNotificationCenter` via Swift/JNI, Windows WinRT toast via
  JNI, Linux libnotify via D-Bus). Falls back to `AwtTrayNotifier` when
  native lib fails to load. Async `requestPermission` +
  `refreshPermission` bridge Nucleus's callback API to `suspend`.
- `DesktopNotificationAutoDispatcher` subscribes to
  `DesktopLocalCache.eventStream.newEventBundles` and fires OS toasts,
  applying a 9-check suppression pipeline: kind allow-list, master
  toggle, per-kind toggle, DND, window-focused, cold-boot
  (event.createdAt < sessionStart or >30s stale), macOS permission,
  semantic accept, 30s per-(kind,event-id) dedupe. Wired in Main.kt
  with DisposableEffect(loggedIn.pubKeyHex); window focus tracked via
  LocalWindowInfo → StateFlow.
- Adds `windows { menu = true; shortcut = true }` to
  `desktopApp/build.gradle.kts` so AUMID persists and Windows toasts
  survive reboot.

**Shared notification filter** (`commons/…/moderation/notifications/NotificationKinds.kt`)
- Extracted from Android's `NotificationFeedFilter`. Exposes
  `SUBSCRIPTION_KINDS` (13 kinds: text, DMs kind 4 + 14 + 1059
  gift-wrap, encrypted-file-header, comments 1111, reactions, reposts,
  generic reposts, channel messages 42, nutzaps 9321, zap receipts
  9735, onchain zaps 8333), `subscriptionFilter(pubKey, since, limit)`
  builder that `FilterBuilders.notificationsForUser` delegates to, and
  `tagsAnEventForUser(event, myPubKey, isTargetAuthoredByMe)` semantic
  gate. Reactions/reposts require target-author-match; other kinds
  require `p=me`. Fixes a bug where the helper defaulted to accept and
  let cache-seed leak "mentioned you" notifications from unrelated
  text notes.
- Android's `NotificationFeedFilter.NOTIFICATION_KINDS` now spreads
  `SUBSCRIPTION_KINDS` + Android-only extras (badges, git, highlights,
  polls, videos, voice, live-activities), so a change on either side
  propagates. Downstream push consumers (`NotificationDispatcher.kt`,
  `EventNotificationConsumer.kt`) read the resulting set transparently.
- Content sanitizer strips control chars, RTL overrides, zero-width
  chars, and URLs from toast titles. DM cards never render ciphertext
  body (decryption pipeline deferred).

**Tests** — `NotificationKindsTest` covers 17 scenarios: reactions
target-author-mismatch rejection, own-event rejection except zap kinds,
p-tag routing for text/DMs/zaps/nutzaps/gift-wraps/channel messages,
`SUBSCRIPTION_KINDS` sanity + `subscriptionFilter` shape.

**Testing constraint**: macOS OS notifications require a bundled
process. `gradle run` will always show BundleRequired — use
`gradle :desktopApp:runDistributable` and open the resulting
`Amethyst.app`. First permission grant surfaces the app in
System Settings → Notifications.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-03 15:19:18 +03:00
Claude 18bd360052 perf: fix O(n²) replay dedup + session-pump backpressure — giant REQs 11x faster
Two server bugs masking each other made a single giant REQ crawl and
then wedge:

LiveEventStore's historical-replay dedup used an immutable Set under an
AtomicReference with copy-on-add — set + id copies the whole set per
streamed event, so large replays were accidentally O(n²) (100k-event
REQ: ~700 events/s, degrading as the response grew). Replaced with a
spin-lock-guarded mutable HashSet (same threads, single contains/add
per critical section).

Fixing that unmasked WebSocketSessionPump's slow-client policy: a fast
replay instantly overflowed the 8192-frame cap — which conflated 'slow
client' with 'replay outruns the socket writer', normal for bulk — and
the 'drop' only closed the internal queue, leaving the socket half-dead
(no EOSE, no close frame, tail silently missing: the likely cause of
the benchmark's 99,998/100,000). Producers are now paced against a full
backlog (bounded blocking wait, consistent with the documented ingest
fanout behavior) and only a client still behind after 30s is dropped,
by actually cancelling the socket.

GiantReqStreamTest guards the regression: 20k-event REQ pre-fix 8.4s
(~2.4k events/s), post-fix 0.7s (~27k events/s), all events + EOSE
delivered. 96 geode tests and the quartz relay/server suites pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 05:07:39 +00:00
Claude b21fc330ac chore: report negotiated websocket compression in the bulk benchmark
The per-connection-wall test prints the negotiated
Sec-WebSocket-Extensions header; against strfry OkHttp negotiates
permessage-deflate (client_no_context_takeover) out of the box, closing
the 'is compression actually on?' question from the optimization list —
it is, no change needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:32:05 +00:00
Claude 4bbfd86a56 feat: add negentropySyncFanOut — multi-connection sync from one relay
One reconcile feeds by-id download batches to N clients (one socket
each) x reqsPerClient workers; reconcile windows also round-robin
across the connections, since a single connection produced need-ids at
only ~9k/s on a 2.6M corpus and starved the downloads. Events funnel
through a bounded channel to a single consumer (exact maxEvents,
single-threaded onEvent); all stages backpressure; localEntries diffing
and have-counting match negentropyReconcile. reconcileWindows became
multi-client internally; single-client paths pass listOf(this).

Production shootout (same-run pairs, 100k cap): +18% to +64% over the
tuned single client, capped by the relay's server-side reconcile id
production rather than download parallelism (which the by-id matrix
shows scales 2.7x with connections). 4 in-process multi-client tests;
18 negentropy tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:27:46 +00:00
Claude 5a993810bb perf: add ParallelEventVerifier — batched Schnorr verify off the receiver
Client-side mirror of IngestQueue.parallelVerify: submit() is a cheap
bounded-channel send from the relay consumer coroutine; a drain loop
batches greedily (up to 256) and fans each batch across
Dispatchers.Default in core-sized chunks, dispatching callbacks in
submission order. preVerified short-circuits already-trusted ids; the
bounded channel backpressures the socket instead of growing heap.

Batch/chunk sizing is measurement-driven: per-event async cost ~40us of
scheduling each (swallowing the gain), and the per-batch join barrier
at 64 still cost half (64 -> 1.2x, 256 -> 2.2x, 1024 -> 3.2x on 4
cores). ParallelVerifyBenchmark (fresh signed events per pass — Event
caches derived state after first verify, so passes must not share
instances) measures 1.8x vs sequential with an enforced >=1.5x
assertion. 4 correctness tests cover valid/tampered routing, ordering,
preVerified and callback-crash resilience.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 04:15:29 +00:00
Claude a134ca3ec8 perf: bound the per-connection websocket receive buffer
The reader-thread-to-consumer channel in BasicOkHttpWebSocket and the
app's OkHttpWebSocket was UNLIMITED: a consumer slower than the socket
accumulated frame Strings without bound (gigabytes over a multi-million
event download). Now capped at 4096 frames — when full, OkHttp's reader
thread blocks and TCP flow control pushes back on the relay instead of
the heap. The trade-off (a blocked reader delays PING/PONG handling) is
documented on the constant; the bound is deep enough that only a
pathologically slow consumer hits it.

BoundedReceiveBufferTest forces sustained backpressure (8-frame buffer,
sleeping consumer, real socket to a local geode relay) and asserts all
events plus EOSE arrive in order with no drops or deadlock.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:51:23 +00:00
Claude b6ea565870 perf: add CachingEventDecoder — duplicate frames reuse the parsed Event
BasicRelayClient's decode step becomes a pluggable MessageDecoder
(default unchanged). The opt-in CachingEventDecoder scans EVENT frames
for their id (~0.3us, JSON-escape-safe so embedded event JSON in repost
content cannot confuse it; any irregularity falls back to full parse)
and on a cache hit synthesizes the EventMessage from the already-parsed
Event with the frame's own subId — every subscription still gets its
delivery and per-relay bookkeeping is unchanged; only the redundant
parse is skipped. Production traffic measured 14-57% duplicate frames.

DedupDecodeBenchmark (60k frames, 67% dups): 10.0us/frame full parse vs
1.5us/frame cached — 6.5x, with an in-benchmark assertion so the gain
is enforced. 7 scan-safety unit tests in CachingEventDecoderTest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:42:55 +00:00
Claude 73e68813f4 perf: shard PoolRequests lock per subscription
The single global spin lock serialized every EVENT frame from every
relay and measured negative scaling (4 concurrent relay consumers
pushed 3.6M deliveries/s aggregate vs 11.1M for one thread alone). The
lock now lives in RequestSubscriptionState — one per subscription —
since all compound mutations are per-subId and different subs share no
wire state. decideCommandLocked takes the state instance to avoid
re-entering the non-reentrant lock; all-subs iterations lock one sub at
a time; withLock is inline to keep the hot path allocation-free.

DispatchStageBenchmark (PoolRequests-only, 1 -> 4 feeders): scaling
flips from 0.33x to 3.4-7.3x across runs. PoolRequests concurrency,
NostrClient and negentropy suites pass unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 03:35:05 +00:00
Claude 5408df0271 feat: add negentropyReconcile — standalone need/have id diff for callers
Splits the reconcile out of negentropySync so callers decide how to
load: negentropyReconcile streams needIds (relay has, local lacks —
download) and haveIds (local has, relay lacks — publish) in batchSize
chunks with back-pressure, taking local state as List<IdAndTime> and
slicing it per created_at window on overflow splits; the accumulating
negentropyReconcileIds convenience returns both lists. negentropySync
now delegates to the same window engine.

NegentropySession's primary constructor takes List<IdAndTime> (JVM
erasure forbids a List<Event> overload); the event-list form moved to
NegentropySession.fromEvents, mirroring NegentropyServerSession, with
all call sites migrated.

Adds NostrClientNegentropyReconcileTest (empty local set, partial
overlap both directions, identical sets, batch streaming, since/until
window slicing) — 49 negentropy tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 02:46:43 +00:00
Claude 2fb44d8166 feat: pipeline negentropySync — global worker pool, concurrent window reconciles
Restructures the sync around the measured bottlenecks: one download
worker pool now spans the whole sync (windows no longer join before the
next reconcile starts), overflow-split windows are reconciled by a
caller-set number of concurrent NEG sessions (reconcileConcurrency,
default 1) from a shared work queue, and the reconcile-to-download
buffer depth is exposed (idBufferBatches). No NIP-11 auto-detection:
peak subscription usage (maxConcurrentReqs + reconcileConcurrency + 1)
is documented and budgeting it against the relay's max_subscriptions is
the caller's call.

All 44 negentropy tests pass unchanged. Production shootout on a 2.6M
corpus, single connection: 3.6k events/s with old-equivalent params,
4.5k/s tuned (12 reqs + 4 reconcilers) — ~82% of the measured ~5.5k/s
per-connection by-id ceiling, vs 1.6k/s for the old implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 02:28:32 +00:00
Claude c193c1a15d feat: extend by-id matrix to relay caps — saturation found at scale
The relay was backfilled from 33k to 2.6M kind-30382 events between
runs, and the picture changed: on the big corpus one connection
saturates at ~8 in-flight REQs (~5.5k events/s) and the relay tops out
at ~15k events/s around 40 total in-flight — past that, added
concurrency only inflates per-REQ latency. Axis 1 now stops at the
relay's NIP-11 max_subscriptions (20; exceeding it wedged the
connection in the first extended run), cells carry a hard 120s budget
with one retry per batch, and per-REQ latency is computed over
completed batches so partial cells report honestly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 01:56:28 +00:00
Claude 466667add5 feat: add parallel fetch-by-id matrix — in-flight REQs, not connections, set throughput
Downloads the full 33k kind-30382 corpus from nip85.nosfabrica.com per
cell over a (connections x concurrent REQs) matrix with pre-connected
sockets. One connection scales near-linearly to 16 in-flight REQs
(1,970 -> 30,735 events/s) with no wall at 4, and 1x16 matches 4x4 —
total in-flight REQs is the real variable. Every slow path measured so
far (serial page cursors ~2-3.7k/s, negentropySync 1.6k/s) is a
pipelining/serialization problem: negentropySync starves its download
workers on reconcile cadence and recurses overflow windows
sequentially. Findings and implied negentropySync improvements in the
plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 00:58:21 +00:00
Claude bce9dc0e60 feat: add per-connection wall test — relay pacing, not client parse
A raw no-parse socket and the full quartz stack page the same
production query on one connection at the same ~3.4-3.8k events/s,
proving the single-connection ceiling reported by a user is the
relay's per-connection response cadence rather than the client's
serial parse (which is ~1% busy at that rate). Findings and the
assessment of the proposed parallel-parse + ordered-dispatch pipeline
are in the plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-03 00:46:17 +00:00
Claude 5600b36018 feat: add bulk-download benchmark with negentropy vs paging test case
Measures how to download millions of events from a single relay as fast
as possible, download+parse only: local geode ceilings (giant REQ vs
until-cursor paging vs created_at-sharded connections, quartz stack vs
raw frames), offline per-frame strategies (full parse, parallel parse,
id-scan for raw archiving), and a production case syncing kind 30382
from nip85.nosfabrica.com via NIP-77 negentropy against plain paging.

Headline results in the plan doc: paging beats giant REQs 26x (which
also dropped frames), created_at sharding stacks ~2x on top, parse is
2-5% of the budget, and negentropy is 2.4x slower than paging for a
cold download (its win is incremental re-sync).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 23:43:30 +00:00
Claude 4aabde2d19 feat: add dispatch-stage benchmark (post-parse, pre-verify path)
Offline microbenchmark of NostrClient's dispatch stage — PoolRequests
state machine, listener fan-out, id dedup and handoff to a verify
stage — under 1 and 4 concurrent relay feeders. Key results: ~100ns per
message uncontended, but negative scaling under concurrency (the
PoolRequests busy-wait spin lock makes 4 feeders slower in aggregate
than 1), per-event channel handoff costs ~180ns vs a free 64-batch,
and early dedup before the locked path gives 2.7-4x aggregate
throughput at production duplicate factors. Findings appended to the
receiver-perf plan doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 23:22:12 +00:00
Claude 4b8cca0d59 feat: add production benchmark for the NostrClient receive path
Gated JVM test (-PprodRelayBench=1) that connects to live relays with
realistic filters and measures per-relay queue delay, processing time,
consumer busy fraction, EOSE latency and duplicate rates, comparing the
current inline verification against a parallel verify stage, plus
offline single-thread parse/verify ceilings on captured frames.

Findings from a first run are written up in
quartz/plans/2026-07-02-nostrclient-receiver-perf.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018saXqYfAa3RvSJoDXK591R
2026-07-02 22:49:42 +00:00
Claude c549bd22df perf: isolate QUIC blocking socket I/O onto dedicated threads
Part A of the dispatchers/thread-caps audit — the one genuine at-scale
starvation the audit found.

UdpSocket.receive() does a blocking DatagramChannel recvfrom that parks its
thread for the ENTIRE life of the connection. It ran via
withContext(Dispatchers.IO) from a read loop already on Dispatchers.IO, so
the blocking call pinned one shared IO-pool thread per connection. Past ~64
concurrent connections that starves ALL other Dispatchers.IO work in the
process — this module's and the host app's alike.

Give each socket two dedicated daemon threads: recvDispatcher for the
perpetually-parked receive and sendDispatcher for the send (they can't share
one thread — the receive would monopolise it). QUIC's blocking socket I/O now
never touches the shared pool. connect() keeps its one-shot DNS/bind on
Dispatchers.IO (setup cost, not a lifetime parker).

close() calls shutdownNow() on both executors: interrupting the recv worker
breaks the parked recvfrom immediately (ClosedByInterruptException, caught as
ClosedChannelException -> receive() returns null), so the threads exit
promptly instead of leaking per closed connection. The closed-check is hoisted
out of withContext so a post-close call fails fast without dispatching onto a
shut-down executor.

Verified: QuicConnectionDriverLifecycleTest (100 session open/close cycles,
asserts thread growth <=16 and no FD leak) passes, confirming the two new
threads per socket are reclaimed on teardown. New UdpSocketTest covers
round-trip, the dedicated-thread isolation, thread shutdown on close, and the
after-close contract. Full :quic:jvmTest suite green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 21:35:57 +00:00
Vitor PamplonaandGitHub 1c72d0dd73 Merge pull request #3455 from vitorpamplona/claude/reasonable-event-kinds-6k0n76
feat: expand the "Let's be reasonable" napplet auto-approve set
2026-07-02 14:48:26 -04:00
Claude c85280259c feat: add reports, torrents, and addressable content to the reasonable set
Complete the "Let's be reasonable" content set:
- reports (1984) and torrents (2003/2004) — additive public events whose
  reputational weight is no greater than the arbitrary kind-1 notes an app
  can already publish.
- long-form articles (30023), wiki (30818), and the legacy addressable
  video kinds (34235/34236) — addressable content. Re-signing with the same
  d tag replaces the app's own prior version; accepted as no worse than the
  arbitrary posting a kind-1 grant already permits.

Only replaceable *configuration* (profile 0, contacts 3, 10000-range lists)
stays ASK, since a bad write there can silently wipe account settings —
distinct from addressable content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:13:34 +00:00
Claude e4c35523a7 feat: expand reasonable set with more public content/engagement kinds
Add the remaining regular, additive, public, plaintext content and
engagement kinds that sit in the same risk class as notes/pictures:
relay chat (9), threads (11), public messages (24), poll votes (1018) and
polls (1068), file metadata (1063), voice messages (1222) and replies
(1244), live-stream chat (1311), and code snippets (1337).

Documents the borderline kinds left at ASK on purpose: reports (1984) and
torrents (2003/2004) carry reputational/legal weight, and
addressable/replaceable content (long-form 30023, wiki 30818) can overwrite
prior versions. Test pins several of these exclusions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 18:08:48 +00:00
Claude 49e0fee162 perf: add expect/actual ConcurrentSet, use for EventDeduplicator
Part B of the dispatchers/thread-caps audit.

EventDeduplicator is fed from relay subscription callbacks
(AdvancedSearchBarState.trackRelayEvent), which arrive on multiple threads
concurrently. It backed a plain mutableSet with a single KmpLock, so every
delivery from every relay thread serialized on one monitor.

Add ConcurrentSet<E> as a KMP expect/actual util:
- jvmAndroid actual: ConcurrentHashMap.newKeySet() — lock-striped writes,
  lock-free reads, no single cross-thread monitor.
- iOS actual: a KmpLock-guarded set (no lock-free set in the K/N stdlib) —
  same behaviour as before, no regression. The win lands on JVM/Android,
  which is where the high-throughput event paths run.

Point EventDeduplicator at it. Covered by ConcurrentSetTest (commonTest,
behaviour) and ConcurrentSetConcurrencyTest (jvmTest, exactly-one-add-per-key
under 8 threads).

Scope note: the other two commonMain sites the audit flagged were left as-is
on purpose. The compose subscription managers' KmpLock is a deliberate,
documented KMP choice on a single (main-thread) writer where the lock is
effectively free; EOSECache is a bounded LRU with compound value mutation on
a per-subscription (not per-event) path. Neither is a clean fit for a
concurrent set, and converting them would fight a documented decision for
negligible gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:53:43 +00:00
Claude ca5ae978fb perf: add lock-free-read ConcurrentLruCache, use on two hot read paths
Part C of the dispatchers/thread-caps audit. Both LnurlEndpointCache and
DesktopCachedRichTextParser were bounded caches backed by a LinkedHashMap
behind a single monitor (@Synchronized / Collections.synchronizedMap with
accessOrder). An access-order map structurally mutates on get, so every
read took the lock — serializing all readers on paths that are hot
(kind-9735 zap-receipt validation; feed rich-text rendering).

Add ConcurrentLruCache<K, V> in quartz utils: storage is a
ConcurrentHashMap so get is lock-free; writes + eviction run under a small
write lock that is off the read path. Eviction is least-recently-put order
(get does not refresh recency) — exactly what LnurlEndpointCache already
did, and fine for the deterministic rich-text parse cache.

Point both caches at the shared helper. Covered by a new
ConcurrentLruCacheTest (round-trip, eviction order, re-put recency
refresh, get-does-not-refresh, clear, and a concurrent size-bound smoke
test); the existing LnurlEndpointCacheTest still passes unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:21:27 +00:00
Claude 7e61d24962 perf: remove needless blocking on two per-packet/per-event hot paths
Part of the dispatchers/thread-caps audit. Two low-risk fixes that remove
thread-blocking work from paths hit on every event / every packet:

- LargeCache (iOS actual): drop the runBlocking wrapper around
  createIfAbsent. The block contained only synchronous CacheMap ops (the
  same get/put getOrCreate already calls without runBlocking), so it was
  pure dispatcher-blocking overhead on the per-event ingest path. Now
  mirrors the JVM actual's plain-function shape.

- QUIC JCA AEADs (AES-GCM + ChaCha20-Poly1305): split the single
  `synchronized(this)` monitor into disjoint encryptLock / decryptLock.
  seal-family touches only encryptCipher + recentEncryptNonces; open-family
  touches only decryptCipher, so a connection's send loop and read loop no
  longer serialize against each other through crypto on every packet. The
  documented defence-in-depth against cross-coroutine Cipher corruption is
  preserved per direction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ANuUziXKRafSTBxbh4SMoq
2026-07-02 16:09:49 +00:00
Claude a99ee8d09e feat: auto-approve video posts and NIP-42 relay auth under "Let's be reasonable"
Add video posts (kinds 21 normal, 22 short) as direct siblings of picture
posts (20) — additive, public, non-destructive content in the same risk
class as the original note set.

Also auto-approve NIP-42 relay auth (22242): an ephemeral proof-of-key
bound to a single relay + challenge (unreplayable elsewhere) that
Amethyst's own client already auto-signs for every logged-in account, so
treating it as background noise for napplets matches existing behavior.

Deliberately still ASK: NIP-98 HTTP auth (27235). Unlike relay auth it
authorizes an arbitrary HTTP request as the user — including destructive
NIP-96 blob deletes and NIP-86 relay-management admin calls — so its blast
radius is too broad to sign silently. Test pins the 42-vs-98 contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 16:04:12 +00:00
Claude 20a9622f27 feat: auto-approve zap requests (9734) under "Let's be reasonable"
Signing a Lightning zap request moves no money — it only fetches an
invoice. The payment itself is the separately-gated value.payInvoice
capability, which prompts on every use regardless of policy. So adding
9734 to the reasonable set drops a redundant signature prompt while the
meaningful payment prompt stays.

Nutzaps (9321) remain excluded: publishing one *is* the payment, since
the event carries the spendable ecash proofs. Test pins the contrast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:49:07 +00:00
Claude b02b5439d5 feat: expand "Let's be reasonable" napplet auto-approve set
Add more additive, public, non-destructive event kinds to the REASONABLE
signer policy so common apps stop prompting for every action. New kinds:
16 (generic repost), 20 (picture post), 42 (public chat message),
1111 (NIP-22 comment), 9802 (highlight), and 30315 (user status) — all in
the same risk class as the original 1/6/7 set.

Kinds that can spend money, overwrite account config (profile, contacts,
relay/mute/bookmark lists), delete content, or leak private data still
prompt. Decryption also stays ASK.

Refactors reasonableDecision() to a documented REASONABLE_SIGN_KINDS set
backed by quartz KIND constants, and adds NostrSignerPermissionLedgerTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WMrHZfwN5tM4ecvdz7xigo
2026-07-02 15:14:59 +00:00
Vitor PamplonaandGitHub 4727669fde Merge pull request #3453 from vitorpamplona/claude/quartz-logging-review-o1l51r
Make Log.sink pluggable for custom logging backends
2026-07-02 09:33:52 -04:00
Claude 167fe96345 refactor(quartz): route stray printStackTrace through the Log facade
printStackTrace() dumps straight to stderr, bypassing both Log.minLevel
and the consumer's Log.sink — the very thing the LogSink work exists to
control. Migrate the five production call sites:

- Lud06: drop two printStackTrace() calls that sat directly above an
  existing Log.w(..., t) carrying the same throwable (pure duplication).
- ElectrumXClient: the swallowed-lookup catch said "Log but don't crash"
  yet used printStackTrace(); route it through Log.w with context.
- OpenTimestamps: log the swallowed merge failure via Log.w; drop the
  print-then-rethrow (the rethrown exception already carries the trace).

Socket-protocol writer.println(...) and README/KDoc println examples are
left as-is — they are wire I/O and documentation, not logging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc
2026-07-02 13:31:47 +00:00
Claude 292473ee26 feat(quartz): let consumers own logging via a swappable LogSink
Quartz already funnels every diagnostic through the `Log` facade, but the
sink was hardcoded per platform (android.util.Log / System.err / NSLog /
println), so a consuming app couldn't route Quartz logs into its own stack
(Timber, SLF4J, Crashlytics, a file, a test buffer, or /dev/null).

Add a `LogSink` fun interface and a replaceable `Log.sink`, defaulting to
`PlatformLogSink` which reproduces the historical per-platform behavior.
All ~225 call sites and the `Log.*` signatures are unchanged; the lazy
`() -> String` overloads still short-circuit on `minLevel` before the
lambda runs, preserving the allocation-free fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EK3TrDkP1EXj1d62oKJMdc
2026-07-02 13:25:59 +00:00
Vitor PamplonaandGitHub 016a5bd8b1 Merge pull request #3452 from vitorpamplona/claude/quartz-searchable-event-audit-fhvjws
Implement SearchableEvent interface for NIP-50 search support
2026-07-02 09:13:10 -04:00
Claude 35d0aa0ebc feat(quartz): index more event kinds for NIP-50 full-text search
Several event classes carried human-readable text (titles, names,
descriptions, prompts, free-text notes) but did not implement
SearchableEvent, so their content never made it into the SQLite FTS
index. Implement SearchableEvent on:

Tier 1 (titles/names/descriptions):
- NIP-15 marketplace: ProductEvent, StallEvent, AuctionEvent,
  MarketplaceEvent (name/description/about parsed from JSON content)
- Podcasting20TrailerEvent (title + content)
- TextNoteModificationEvent (proposed text + edit summary)
- GitStatusEvent base -> covers kinds 1630-1633 (status message)
- NIP-29 EditMetadataEvent (group name + about; added name()/about())

Tier 2 (free-text prose / labels):
- LiveActivitiesRaidEvent (raid message)
- CalendarRSVPEvent (RSVP note)
- MintRecommendationEvent (mint review)
- LabelEvent (content + label values)
- P2POrderEvent (maker name, currency, payment methods)
- NIP90 request events: text generation (prompt), image generation
  (prompt + negative prompt), text-to-speech (text)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5MN4vF4JJG7xFCofJAHg8
2026-07-02 13:10:20 +00:00
Vitor PamplonaandGitHub d9c423eabf Merge pull request #3449 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-02 08:43:29 -04:00
Vitor PamplonaandGitHub 13f65dec26 Merge pull request #3450 from vitorpamplona/claude/compose-signature-field-u7rbx6
Add compose signature setting to auto-append custom text to posts
2026-07-02 08:42:20 -04:00
vitorpamplonaandgithub-actions[bot] 5c40187ba0 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-02 11:05:32 +00:00
Vitor PamplonaandGitHub 8a618a92b0 Merge pull request #3432 from nrobi144/feat/desktop-privacy-lock
feat(desktop): Privacy lock for Messages column
2026-07-02 07:02:59 -04:00
nrobi144 cac54001da chore: retrigger CI after flaky macOS AppStateMachineTest
AppStateMachineTest.bootstrapSubscriptionFiresAtMostOncePerAccountLoad
hit a ConcurrentModificationException on macos-latest only. Test passes
locally 5/5 runs and every other CI check on this PR is green
(lint, Linux DEB, Windows MSI, Android, iOS, Compose smoke). Empty
commit to re-run the macOS DMG job.
2026-07-02 10:17:06 +03:00
nrobi144 33bb81dddb Merge remote-tracking branch 'upstream/main' into feat/desktop-privacy-lock
# Conflicts:
#	desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt
2026-07-02 07:20:44 +03:00
Claude b0834b8d8a feat: add compose signature pre-filled in text-based post screens
Adds a Signature field to Compose Settings (global UI settings, DataStore
persisted). When opening any text-based composer — new note, reply, quote,
poll, NIP-22 comment (reply/hashtag/geohash/url), or a new long-form
article — the signature is appended to the message with a blank line,
keeping the cursor at the start so the user types above it.

Drafts, forks, and version edits are skipped since their content already
carries (or deliberately omits) a signature, and an untouched
signature-only message is treated as blank so closing the composer never
auto-saves a junk draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vq4JQPB9m62nJ8Vdp7xVLN
2026-07-02 03:41:00 +00:00
Vitor PamplonaandGitHub e6728f8393 Merge pull request #3447 from vitorpamplona/claude/light-theme-pulldown-styling-2avguo
Remove elevation from bottom and top sheet surfaces
2026-07-01 20:24:17 -04:00
Vitor PamplonaandGitHub 05eb6db9cd Merge pull request #3446 from vitorpamplona/claude/relay-damus-shutdown-hlno35
Centralize default search relays into DefaultSearchRelayList
2026-07-01 20:23:35 -04:00
Claude 690bab0f0f fix: drive search-relay examples text from DefaultSearchRelayList
The "Good options are: …" hint in the search-relay setup dialog listed a
hardcoded, drifting subset of relays (and the zh locales had corrupted
hostnames like "reiny.nostr.band"). Make it dynamic instead:

- search_relays_not_found_examples now ends in a %1$s placeholder across
  all 57 locales.
- AddSearchRelayListDialog fills it from
  DefaultSearchRelayList.joinToString { " - ${it.displayUrl()}" }, so the
  hint always reflects the shared AmethystDefaults search set and stays in
  sync with the "reset to defaults" button right below it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-02 00:11:38 +00:00
Claude 44edf3314a fix: remove Material 3 elevation tint from browser pull sheets on light theme
The embedded browser's top pull-down (TopControlSheet) and bottom pull-up
(BottomConsoleSheet) both drew with tonalElevation. Material 3 recolors a
Surface only when its color is EXACTLY colorScheme.surface, swapping in
surfaceColorAtElevation() which blends surfaceTint (= primary, the Amethyst
purple) over the surface. On the light theme that near-white + purple mix
reads as a bright pink/lilac cast instead of the plain background.

- TopControlSheet: drop tonalElevation to 0 so it renders the plain
  background color; keep shadowElevation to lift it off the page.
- BottomConsoleSheet: drop both elevations. It docks flush against the
  bottom navigation bar, so shadowElevation cast a shadow onto that bar (a
  seam breaking the flush look); the tonalElevation had no color effect
  anyway since its color isn't exactly colorScheme.surface. The grabber and
  divider still separate the panel from the page above it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJ2VbDz7C6hvHVsLpdecYf
2026-07-02 00:07:00 +00:00
Claude 461aa57b57 fix: use AmethystDefaults search relays and drop dead relay.nostr.band
relay.nostr.band has been decommissioned. Remove it from every runtime
relay list and route search-relay defaults through the shared
AmethystDefaults.DefaultSearchRelayList in commons:

- amy NipCommand: SEARCH_RELAYS now = DefaultSearchRelayList (drops the
  hardcoded relay.nostr.band/nostr.wine pair; RelayUrlNormalizer import
  no longer needed).
- desktop DesktopRelayCategories: DEFAULT_SEARCH_RELAYS now =
  DefaultSearchRelayList instead of a single relay.nostr.band entry
  (which would otherwise be empty after removal).
- desktop DefaultRelays and FollowPacks DISCOVERY_RELAYS: drop
  relay.nostr.band.
- Update NIP-50 example hostnames in desktop comments, the search-relay
  editor help text, and the localized search_relays_not_found_examples
  string across all locales to nostr.wine.

Preview sample data, captured sample-event JSON, and quartz test
fixtures that mention relay.nostr.band are left untouched (no runtime
effect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:59:15 +00:00
Claude b1fda59cd6 fix: drop relay.damus.io from default relay lists ahead of shutdown
relay.damus.io is being decommissioned, so remove it from every runtime
default/fallback relay set to stop the app and amy from wasting connection
slots on a dead host:

- commons Constants: remove `damus`; it dropped out of `bootstrapInbox`
  (default NIP-65 inbox) and `eventFinderRelays` (default outbox/fallback),
  both still carrying 6 healthy relays.
- ChessConfig: remove damus from CHESS_RELAYS / CHESS_RELAY_NAMES, leaving
  the 3 relays the FETCH_TIMEOUT comment already assumes.
- desktop DefaultRelays: remove damus and the also-dead relay.snort.social.
- desktop FollowPacks DISCOVERY_RELAYS: remove damus.
- amy NipCommand SEARCH_RELAYS: swap damus for the NIP-50-capable nostr.wine.

Comments, @Preview sample data, and test fixtures that mention damus.io are
left untouched — they have no runtime effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Ttcqa3V78bugGraGhtehj
2026-07-01 23:35:24 +00:00
David KasparandGitHub 572fdb5ff4 Merge pull request #3438 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-01 22:21:00 +01:00
vitorpamplonaandgithub-actions[bot] 73c9e2086a chore: sync Crowdin translations and seed translator npub placeholders 2026-07-01 21:19:15 +00:00
Vitor PamplonaandGitHub 19d1167ccd Merge pull request #3441 from vitorpamplona/claude/podcast-event-kinds-merge-vv24gd
Add podcast authoring UI and NIP-XX Podcasting 2.0 support
2026-07-01 17:16:46 -04:00
Vitor PamplonaandGitHub 4a66435263 Merge pull request #3445 from vitorpamplona/claude/sqlite-event-store-no-fts-9k6y8a
Add optional full-text search indexing toggle to SQLiteEventStore
2026-07-01 17:14:22 -04:00
Claude 8b938396a0 refactor(quartz): move FTS toggle into IndexingStrategy
Fold the full-text-search on/off switch into `IndexingStrategy` as
`indexFullTextSearch` (default `true`) instead of a separate top-level
`enableFullTextSearch` constructor param on `EventStore`/`SQLiteEventStore`.

`IndexingStrategy` is already the single place that decides which indexes
the store builds — every field is a per-index toggle with a size/speed
tradeoff, and `QueryBuilder` already receives it — so FTS, being just
another index, belongs there rather than split across two config surfaces.

Behaviour is unchanged: the module's no-op path and the QueryBuilder
"search matches nothing" guards now read the flag via the strategy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
2026-07-01 21:11:26 +00:00
Claude cd4edefce0 feat(quartz): allow SQLite event store without FTS indexing
Add an `enableFullTextSearch` flag (default `true`) to `EventStore` and
`SQLiteEventStore` so deployments that never serve NIP-50 search from
SQLite — e.g. a relay that offloads search to an external engine like
Vespa — can skip the full-text-search write cost.

When disabled:
- `FullTextSearchModule` becomes an inert no-op: the `event_fts` virtual
  table and its `fts_foreign_key` delete trigger are never created,
  inserts skip `indexableContent()` + tokenization, and both reindex
  entry points return immediately.
- `QueryBuilder` short-circuits any query/count/delete filter carrying a
  non-empty `search` term to a "matches nothing" result (an empty-string
  search still imposes no constraint), so no SQL ever references the
  absent `event_fts` table. In a multi-filter union the search branch
  contributes nothing while the other filters resolve normally.

Everything else (replaceable/addressable handling, deletions,
expirations, right-to-vanish, negentropy) is unchanged, and the default
keeps FTS on for existing callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BjzUpY8H31c7ux669zytWg
2026-07-01 21:00:06 +00:00
Vitor PamplonaandGitHub daa453c3c3 Merge pull request #3444 from vitorpamplona/claude/bottom-nav-reset-defaults-jxg5z0
Persist bottom bar defaults as blank sentinel for auto-migration
2026-07-01 16:59:01 -04:00
Claude d723e93e7a fix(quartz): drop commas from podcast test names for Kotlin/Native
Kotlin/Native (the iOS test target) rejects commas in backtick function
names, so test-quartz-ios failed to compile even though jvmTest — which
allows them — passed. Rename the two offending tests. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 20:55:58 +00:00
Claude 679c337427 feat(amethyst): persist default bottom bar as a sentinel so resets auto-migrate
The bottom-bar settings screen already offers a "Restore defaults" action, but it
saved the concrete default list to disk. That pinned the user to the default of
the version they reset on, so a future release that changes DefaultBottomBarEntries
would never reach them. The same happened to users who never touched the bar: any
unrelated settings save wrote the concrete default list.

Persist "follow the defaults" as a blank sentinel instead. On load a blank value
already resolves to the current DefaultBottomBarEntries, so whenever the default
changes in a later version, every user on the defaults is migrated automatically.
Genuinely customized bars are still stored verbatim as JSON.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011EBk58qUYuyKxz9Ju4VpEq
2026-07-01 20:50:42 +00:00
Vitor PamplonaandGitHub 9c191137d5 Merge pull request #3443 from vitorpamplona/claude/nip11-document-builder-gq7eeq
Add type-safe DSL builder for NIP-11 relay information documents
2026-07-01 16:45:31 -04:00
Claude 266f59959e Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 20:30:27 +00:00
Claude 8085f82ca4 feat(podcasts): standard ReactionsRow on each episode in the Podcast screen
Replace the removed per-episode comment chip with the full NoteCompose
ReactionsRow (comment / zap / react) on every episode row in a podcast's
screen — same engagement affordance as the show header and every other note.
addPadding = false so it aligns within the row's existing padding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 20:27:37 +00:00
Vitor PamplonaandGitHub 03c71d1d4e Merge pull request #3440 from davotoula/fix/sonar-encoding-nul-bytes
Escape raw NUL bytes to fix Sonar encoding warning
2026-07-01 16:27:23 -04:00
Vitor PamplonaandGitHub 02df0672e7 Merge pull request #3442 from vitorpamplona/claude/nip05-function-docs-rcebl7
docs: Add NIP-05 identifier resolution guide and update skill
2026-07-01 16:24:13 -04:00
Claude 2f66c31f43 docs(quartz-integration): document the NIP-11 relay info builder
Add a "NIP-11 Relay Information Document" section to the quartz-integration
skill (the guide AI consults when using Quartz in external projects),
covering the relayInformation { } DSL, serving it over
application/nostr+json, the nested limitation/fees/retention builders, and
the limitation(RelayLimits) sync overload. Also add Quick Reference rows
and extend the skill trigger description to the run-a-relay case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
2026-07-01 20:23:35 +00:00
Claude c871597fc9 feat(quartz): add a type-safe NIP-11 relay info builder
Relay operators wiring up a Quartz-based relay had to hand-write the
NIP-11 document as a large JSON string, which is error-prone and drifts
from the model. Add a DSL builder so they can describe the document in
Kotlin instead:

    val info = relayInformation {
        name = "sot"
        description = "NIP-50 profile search ranked by Nostr web-of-trust"
        software = "https://github.com/vitorpamplona/sot"
        version = "0.1"
        supports(1, 11, 42, 50)
    }
    call.respondText(info.toJson(), ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))

The builder covers every field, with nested `limitation { }` / `fees { }`
DSLs, repeatable list helpers, a `retention(...)` entry adder, and a
`limitation(RelayLimits)` overload that advertises exactly the limits the
relay enforces so the two can't drift.

Also fix FlexibleIntListSerializer to emit numeric `supported_nips` as
JSON integers ([1,11,42,50]) instead of quoted strings, matching the
NIP-11 spec; non-numeric ids still fall back to strings. Geode's default
document now builds via the DSL.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C64Yy2d3na7Y28u7GRrHMX
2026-07-01 20:19:03 +00:00
Claude 4147298494 docs: explain NIP-05 identifier resolution in nostr-expert skill
AI agents were hand-rolling their own hex/npub/nprofile/NIP-05 resolvers
(e.g. a bespoke `resolveObserver` with its own well-known fetch and JSON
parse) instead of using `resolveUserHexOrNull` in
`quartz/nip05DnsIdentifiers/`, which already handles every identifier form.

Add discoverable explainers so the canonical functions surface before an
agent reaches for a hand-rolled version:

- New reference `references/nip05-identifiers.md`: full API surface
  (`resolveUserHexOrNull`, `Nip05Client`, `Nip05Id`, `Nip05Parser`,
  `KeyInfoSet`, Namecoin `.bit`, `OkHttpNip05Fetcher`) plus the
  hand-rolled anti-pattern to avoid and the  replacement.
- SKILL.md: new "Resolving User Input to a Pubkey" section, a
  When-to-Use bullet, Bundled Resources + Quick Reference rows, and the
  identifier-resolution trigger added to the skill description.
- nip-catalog.md: fix the stale NIP-05 entry (referenced a nonexistent
  `Nip05Verifier.kt`) to point at `UserHexResolver.kt` / `Nip05Client.kt`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bb8txetygxSnLXHJhPZSwH
2026-07-01 20:14:06 +00:00
Vitor PamplonaandGitHub 33abc8acb9 Merge pull request #3439 from vitorpamplona/claude/quartz-optional-auth-policy-gkf5b9
Add OptionalAuthPolicy for NIP-42 without requiring authentication
2026-07-01 15:42:17 -04:00
Claude f7afc56752 feat(quartz): add optional NIP-42 AUTH relay policy
Introduce OptionalAuthPolicy, a relay-server policy that runs the full
NIP-42 challenge/verify handshake — emitting the AUTH challenge on connect
and recording verified pubkeys into the connection scope — but never
requires it: EVENT, REQ, and COUNT are always accepted, so clients that
ignore the challenge keep working.

It subclasses FullAuthPolicy and only relaxes the EVENT/REQ/COUNT gates, so
the authorize() hook and per-connection authenticatedUsers set behave
identically; downstream policies can still gate or rewrite on caller
identity. Wire it into geode via an optional_auth config option and a
--optional-auth CLI flag (ignored when require_auth/--auth is set, since
mandatory AUTH already sends the challenge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJWy4dBBYLbqthhLvbcZh7
2026-07-01 19:30:19 +00:00
davotoula f7dd2c210b fix: escape raw NUL bytes to fix Sonar encoding warning
Four Kotlin sources embedded literal NUL (0x00) bytes, used as string
separators and as literal characters in KDoc comments. A NUL is valid
UTF-8 (U+0000) so decoders accept it, but SonarScanner flags an embedded
NUL in a text source as a file-encoding problem, and git tracked these
files as binary.
2026-07-01 21:25:27 +02:00
Claude 9ac1ba692f feat(podcasts): Top Supporters leaderboard, in-app chapters, transcript viewer
Brings three of PodStr's engagement/reading widgets to Amethyst's podcast
screens, reusing existing infrastructure:

- Top Supporters — a sats-ranked zap leaderboard on the show header, top 3
  flagged with gold/silver/bronze medals, tap-through to profiles. Aggregates
  the show note's zaps through the same LiveActivityTopZappersAggregator the
  live-stream leaderboard uses. Anonymous zaps collapse into one bucket.
- In-app Chapters — fetches the Podcasting-2.0 chapters.json referenced by the
  episode's `chapters` tag and renders a collapsible, tappable list; tapping a
  chapter seeks the live media controller (same seek path as soundbites).
- Transcript viewer — fetches the `transcript` file and shows it in a
  collapsible scrollable panel, stripping VTT/SRT scaffolding into flowing text.

Adds PodcastRemoteContent (a bounded URL text fetcher) for the two off-event
side files. Also removes the now-redundant per-episode comment chip from the
episode list rows — the show ReactionsRow and the episode thread already cover
commenting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 19:22:19 +00:00
Vitor PamplonaandGitHub d0497eaed7 Merge pull request #3437 from vitorpamplona/claude/ai-submissions-spotless-check-gftv4v
Add pre-push Spotless formatting gate
2026-07-01 14:57:14 -04:00
Claude be58d98d2b chore: gate remote submissions on spotless formatting for AI sessions
Add a PreToolUse hook (.claude/hooks/pre-push-spotless.sh, wired in
.claude/settings.json) that runs spotlessApply before a git-push subcommand
or the create_pull_request MCP tool. If spotless reformats tracked Kotlin,
or reports a non-autofixable lint, the call is blocked so the fix is
committed first -- turning CI's spotlessCheck failure into an in-session
block. Gradle infra/network failures warn and allow, leaving CI as the
backstop. Boundary detection tokenizes the command so the words appearing
in a commit message do not trip the gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nWg1Wwq5xCE7kPFzWY95o
2026-07-01 18:48:15 +00:00
Claude dc9579f994 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-07-01 18:39:59 +00:00
Vitor PamplonaandGitHub 4e90fbcc2c Merge pull request #3436 from vitorpamplona/claude/download-button-blossom-uri-ii41o1
Support Blossom URI scheme for media downloads
2026-07-01 14:39:04 -04:00
Claude f35d40cc00 style: drop redundant EOL comments flagged by ktlint in Hex.kt
New KDoc blocks on isHex/isHex64 already state the "~47ns" and
"~30% faster" perf notes, so the trailing EOL comments between the
KDoc and the function now trip ktlint's standard:no-consecutive-comments
rule ("an EOL comment may not be preceded by a KDoc"). Remove them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
2026-07-01 18:33:19 +00:00
Claude 0689a7d244 fix: resolve blossom: URIs when saving media to gallery
The download/save-to-local button handed the raw content URL straight to
OkHttp. For BUD-10 `blossom:` URIs this failed with "expected scheme http
or https but was blossom" because OkHttp only speaks http/https.

Resolve `blossom:` URIs to a concrete server URL via BlossomServerResolver
(the same resolver the Coil/ExoPlayer pipeline uses) before downloading, in
both save entry points (AccountViewModel.saveMediaToGallery and the zoomable
dialog's save action). When no hosting server can be found, the save reports
an error instead of crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BawgXifvcPidMMqJ719Ka2
2026-07-01 18:32:34 +00:00
Vitor PamplonaandGitHub 308058d242 Merge pull request #3435 from vitorpamplona/claude/quartz-hex-utilities-docs-e4eawy
Document Quartz utilities: hex, time, random, hashing, bech32
2026-07-01 14:17:01 -04:00
Claude a98ec62004 feat(podcasts): render nostr-native podcast:person credits as real profiles
When a Podcasting-2.0 person's href points at an npub/nprofile (bare,
nostr: URI, or an njump-style link), upgrade the free-text credit to a real
Nostr profile: the standard ClickableUserPicture + UsernameDisplay, tappable
through to the profile. Plain web links keep the free-text card with the
default profile-image loader.

- quartz: PodcastPerson.nostrPubKey() resolves href → pubkey via Nip19Parser
  (npub/nprofile only). Covered by PodcastPersonSoundbiteTest.
- UI: PodcastPeople branches per person — LoadUser + standard profile
  components for nostr identities, free-text card otherwise — sharing one
  card scaffold so both look identical in the Hosts & Guests strip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 18:15:24 +00:00
Claude 2e2dbcb16b docs(quartz): document core utilities (time, random, hashing, bech32, strings)
Same discoverability treatment as the Hex helpers: KDoc on the reusable,
heavily-used primitives external integrators and AIs kept missing, plus
skill coverage.

- TimeUtils: object + key-fn KDoc emphasizing Unix *seconds* (created_at)
  vs the millisecond nowMillis() exception.
- RandomInstance: object + per-fn KDoc (secure random; use over kotlin.random).
- sha256(): KDoc pointing event-id work to EventHasher.
- EventHasher: object + fn KDoc on canonical id serialization / verification.
- StringUtils: KDoc on the allocation-free case-insensitive matchers + DualCase.
- Bech32: "use NIP-19 helpers unless you need a custom prefix" pointer; KDoc
  on bechToBytes.
- quartz-integration skill: new "Everyday utilities" section (time/random/
  hashing/bech32/base64) + quick-reference rows.
- nostr-expert skill: "Core Utilities" section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
2026-07-01 18:00:45 +00:00
Claude 68a28faab2 docs(quartz): document Hex/HexKey utilities for discoverability
AI agents integrating Quartz were re-implementing hex encoding or pulling
in third-party codecs because the built-in helpers weren't discoverable.

- Add KDoc to the `HexKey` typealias, its extension functions
  (`toHexKey`/`hexToByteArray`/`hexToByteArrayOrNull`/`isValid`) and the
  `PUBKEY_LENGTH`/`EVENT_ID_LENGTH` constants.
- Add KDoc to the `Hex` object and its public API
  (`isHex`/`isHex64`/`decode`/`encode`/`isEqual`).
- Add a dedicated "Hex utilities" section + quick-reference rows to the
  quartz-integration skill, and fix the wrong `HexKey.decodeHex(hex)`
  snippet (no such API) to the real `hex.hexToByteArray()`.
- Add a "Hex Encoding" section to the nostr-expert skill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011HM5uueF4a17umGpjC8wcz
2026-07-01 17:17:23 +00:00
Claude 429ec9177e feat(podcasts): Podcasting-2.0 person credits and soundbites
Adds two Podcasting-2.0 features to the podcast stack:

Persons (podcast:person) — hosts/guests as free-text credits with role,
avatar, and link (not necessarily Nostr users):
- quartz: PodcastPerson model; PersonTag ["person", name, role, img, href]
  on kind 30054 episodes; a persons[] array in the kind 30078 show JSON.
  Exposed via PodcastEpisode.episodePersons() / PodcastShow.showPersons().
- UI: PodcastPeople — a "Hosts & Guests" avatar strip (robohash fallback,
  tap opens href), shown on the episode card and the show header.

Soundbites (podcast:soundbite) — highlight clips:
- quartz: PodcastSoundbite model; SoundbiteTag ["soundbite", start, dur,
  title?] on episodes; PodcastEpisode.episodeSoundbites().
- UI: PodcastSoundbites — "jump to the good part" chips under the audio
  player that seek the live media controller to the clip's start.

Both parse leniently, round-trip through build(), and are covered by
PodcastPersonSoundbiteTest. NIP-F4 returns empty for both (no such tags),
so nothing renders there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 16:31:33 +00:00
Claude ec1ea54dcc feat(podcasts): standard ReactionsRow for the show between header and episodes
Give the podcast show itself the usual engagement affordance: the standard
NoteCompose ReactionsRow (comment / zap / react, with counts) now sits
between the show header and the episode list on the detail screen, bracketed
by the usual dividers like any other content detail. Acts on the resolved
show note and only renders once that event loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 15:46:56 +00:00
Claude 9d4cb99398 feat(podcasts): surface NIP-22 comments on episode list rows
Episode comments already work end to end — replying to an episode routes to
the NIP-22 GenericCommentPost composer (kind 1111 scoped to the episode
address), the thread datasource fetches them via #a/#A, and the thread view
renders them with a full reactions row. The only gap was discoverability on
the podcast-specific surfaces.

Add a comments chip (comment glyph + live reply count via
observeNoteReplyCount) to PodcastEpisodeListItem. It reads "N comments" (or
"Comment" when empty) and opens the episode's thread, where the discussion
lives and new comments are composed. Reuses the existing thread/composer
machinery — no new event plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 15:19:39 +00:00
Claude 69f47f2351 feat(podcasts): add NoteCompose 3-dot options menu to the detail top bar
The single-podcast screen's top bar now shows the standard MoreOptionsButton
(the NoteCompose 3-dot menu) to the right of the bookmark button, giving the
show/episode the usual note actions (share, report, mute, etc.). Both appear
only once the show metadata has resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 14:57:25 +00:00
Claude a61ec2911c fix(podcasts): compact bookmark button, drop the confirmation toast
- The bookmark toggle was a Material IconButton (48dp minimum touch
  target), so it stood taller than the titleLarge line it sits beside and
  broke the side-by-side alignment. Render it as a compact clickable glyph
  sized to a single title line (iconSize, default 20dp) so title and button
  align.
- Drop the add/remove toast — the icon flipping filled/outline is enough
  feedback — and remove the now-unused strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 14:27:40 +00:00
Vitor PamplonaandGitHub 2cb058b323 Merge pull request #3434 from vitorpamplona/claude/quartz-negentropy-sync-accessory-r92bue
Add NIP-77 negentropy sync with streaming and windowing support
2026-07-01 10:24:12 -04:00
Claude 322e33678a perf(quartz): make the negentropy idle watchdog allocation-free
IdleClock.bump() is called for every message the relay sends — the connection
listener bumps it per event, so a multi-million-event download bumped it millions
of times. It stored a ValueTimeMark into an AtomicReference, and since the value
class boxes when used as a generic type argument, every bump allocated a heap
object. That is needless GC pressure on the hottest path (and battery/jank on
Android).

Replace it with a single monotonic base mark taken once (stored unboxed) plus a
@Volatile Long of nanos-since-start updated on each bump — zero allocation per
bump, and only visibility (not atomicity) is needed since each relay's bumps come
from its single reader thread and the driver only reads. Behavior is unchanged;
negentropy + concurrency suites pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 14:18:31 +00:00
Claude 55e945a5ed feat(quartz): idle watchdog for negentropySync instead of a fixed per-round timeout
The fixed `timeoutMs` (default 30s) applied to every reconcile round, but the
FIRST round on a large relay is a legitimate long silence while the relay builds
its whole negentropy fingerprint — observed at ~63-68s for a 3.5M-event kind:0
set on a real relay. So the old default spuriously failed big syncs with
UNAVAILABLE ("reconcile round timed out"), even though the relay was working fine
and would have answered seconds later.

Replace it with `idleTimeoutMs` (default 120s): the maximum time the relay may go
COMPLETELY SILENT before giving up. It resets on every message the relay sends —
each NIP-77 round and every download EOSE/event — and on connect, so a genuinely
slow but progressing sync runs for as long as it needs; only true silence trips
it. Because the watchdog is fed by a connection-level listener that sees all of
the relay's traffic, download activity extends the reconcile deadline and vice
versa. `idleTimeoutMs = 0` disables it entirely (run until the socket drops); the
initial connect and each download batch keep their own finite bounds so an
unreachable relay or a single stuck batch still can't hang the pipeline.

Liveness of a dead/half-open socket does not depend on this: the WebSocket
keep-alive (ping/pong) detects it and the disconnect is already turned into a
clean NEG-ERR abort.

Verified against wss://wot.grapevine.network: the first round took 68.4s (the old
30s default would have thrown UNAVAILABLE) and the sync sailed through it under
the 120s idle window. Negentropy unit suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 14:02:57 +00:00
Vitor PamplonaandGitHub ccba020012 Merge pull request #3430 from vitorpamplona/dependabot/github_actions/actions-14298bd68c
chore(actions): bump the actions group with 4 updates
2026-07-01 09:29:02 -04:00
Vitor PamplonaandGitHub 7963153744 Merge pull request #3433 from davotoula/fix/gzip-test-resource-extension
Rename gzip test corpus to .json.gz to clear Sonar file-encoding warning
2026-07-01 09:28:03 -04:00
Claude 056d54434e fix(podcasts): bookmark button now reflects state + confirms with a toast
PodcastBookmarkButton read `note in bookmarks.public` — an identity-based
List<Note> containment that is unreliable for addressable shows/episodes
(the rendered note instance may differ from the one rebuilt from the
a-tag), so the icon never flipped and there was no way to tell a podcast
was already bookmarked.

Switch to the public bookmark id/address sets (the same reactive pattern
the working git-repository bookmark button uses): match note.address for
addressable notes and note.idHex otherwise. The icon now reliably shows
bookmarked vs not (filled/primary vs outline/onSurfaceVariant), and each
tap fires a confirming toast so the action is never silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-07-01 13:22:31 +00:00
Claude 23722969fd refactor(quartz): reuse one subscription id across fetchAllPages pages
Revert the fresh-subId-per-page workaround (ed5c25e2) now that the underlying
double-REQ race is fixed at the root in PoolRequests. Relays cap the number of
concurrent subscriptions per connection, so a single reused id — opened per page
with the page's `until`, closed before the next page — keeps the whole download
to one subscription slot instead of churning through a distinct id each page.

Safe because the pool now serializes the "send a REQ" decision: after a page's
EOSE, the auto-resend and the loop's unsubscribe+resubscribe can no longer both
fire a REQ for the same id (guarded by PoolRequestsConcurrencyTest). Unit suites
(negentropy paging scenarios, subscriptions) pass; full-scale real-relay
verification to follow before push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 13:01:12 +00:00
Vitor PamplonaandGitHub 3246f13044 Merge pull request #3431 from nrobi144/feat/desktop-hashtag-spam-filter
feat(desktop): hashtag-spam filter with collapse-with-reveal
2026-07-01 07:35:37 -04:00
nrobi144 9d8856efea fix(privacylock): provide CompositionLocals inside App() for tests
CI failure: AppStateMachineTest called App() directly, bypassing
the outer Window { CompositionLocalProvider } shell in Main.kt.
DesktopMessagesLockGate read LocalMessagesLockState and hit the
compositionLocalOf { error(...) } trap.

Fix: construct the state holder + provide both LocalMessagesLockState
and LocalPrivacyLockSettings INSIDE App() itself. Extracted the body
of App() into a private AppInner() so the provider can wrap it
cleanly. The outer providers are removed from Main.kt — no longer
needed.

Trade-off: MessagesLockState is now scoped to App() (via
rememberCoroutineScope) instead of windowScope. That means it
rebuilds on appRestartKey change, which is intentional — an app
restart should reset the coroutines too. The seeded initial value
is still read synchronously from prefs so the first composition
sees the correct LockState (deep-link race fix preserved).

Also fixes an unrelated `!!` warning on existingHash in
SetPasswordDialog by using a safe smart-cast check.
2026-07-01 12:53:32 +03:00
nrobi144 72c3dff870 feat(privacylock): P0 security hardening — 600k iterations + backoff
Addresses the P0 items in the security review at
docs/plans/2026-07-01-privacy-lock-security-review.md.

## PBKDF2 iterations 100k → 600k (M1) via versioned hash format (M2)

- New PasswordHasher storage format: `v1$saltB64$hashB64` (600k
  iterations, matches OWASP 2023 Password Storage Cheat Sheet for
  PBKDF2-HMAC-SHA256).
- Legacy `saltB64$hashB64` (100k iterations) format still verifies
  correctly — no user gets locked out by the bump.
- `hash()` always produces `v1$…`; users migrate to v1 opportunistically
  when they Change or Set a new password.
- New `PasswordHasher.isLegacyFormat()` helper for callers that want
  to force-migrate on next successful unlock.
- Verify cost goes from ~50ms → ~250ms on a modern laptop — well
  within tolerable UX for a lock users open a handful of times per
  session.

## Exponential backoff on failed unlock (M3)

- `PrivacyLockSettings` gains `failedUnlockAttempts: StateFlow<Int>`
  and `lockedUntilEpochMs: StateFlow<Long?>`, both persisted via
  java.util.prefs so a reboot cannot reset the backoff.
- `MessagesLockState.onFailedUnlockAttempt(nowMs)` implements the
  schedule: no lockout for first 4 fails, then 30s / 60s / 120s /
  300s (capped at 5 min).
- `MessagesLockState.onUnlockSuccess()` transparently clears the
  attempt counter and any active lockout (also called from the
  banner-enable path).
- DesktopLockScreen shows a countdown ("Try again in 27s") in the
  supportingText, disables the password field and Unlock button
  during lockout, ticks every 500ms via a LaunchedEffect.
- RemovePasswordDialog inherits the same protection — Settings can't
  bypass the throttle by disabling the lock.
- 4 new unit tests cover threshold behavior, base trip, doubling +
  cap, reset on success. All 13 tests green.

## Not in this commit

- L1/L2 (String/CharArray memory retention) — out-of-tree fix in
  Compose; accepted per threat model.
- L3 (post-uninstall prefs) — release-notes item.
- M4 (Limitations copy update) — deferred; existing "does not
  protect against filesystem access" line already covers.
2026-07-01 12:06:45 +03:00
davotoula 06d1c1c7ee chore: mark *.gz as binary in .gitattributes 2026-07-01 10:54:16 +02:00
davotoula 385f0bd459 fix(quartz): rename gzip test corpus to .json.gz to clear Sonar encoding warning 2026-07-01 10:54:15 +02:00
David KasparandGitHub 9f3cc60a6a Merge pull request #3429 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-01 09:44:43 +01:00
nrobi144 0ff4ef1e10 docs(privacylock): manual testing sheet, banner plan, security review
- docs/plans/2026-06-30-privacy-lock-manual-testing.md — 10-path
  manual QA sheet covering setup, lock behavior, timer, deep-link
  race, change/remove password, banner discovery, cross-run
  persistence.
- docs/plans/2026-07-01-feat-messages-first-run-lock-banner-plan.md —
  design doc for the discovery banner shipped in aadbf3601.
- docs/plans/2026-07-01-privacy-lock-security-review.md — honest
  review of PasswordHasher (PBKDF2 100k / 16B salt) + prefs storage.
  Flags 4 medium-severity items (iterations below OWASP 2023 rec;
  no versioned hash format; no UI-layer rate-limiting; unencrypted
  prefs storage) with concrete fixes. All within accepted threat
  model but P0 items are cheap and high-value follow-ups.
2026-07-01 11:35:52 +03:00
nrobi144 f34c79c848 feat(privacylock): require password to disable + clear on remove
Toggling the Messages lock OFF now prompts for the current password
via a new RemovePasswordDialog. On successful verification, the
password hash is cleared AND the lock is disabled — re-enabling
later requires setting a fresh password.

Rationale: a user should not be able to disable the lock without
proving they know the password. Clearing the hash on remove prevents
a "silent re-enable" attack where someone toggles OFF then ON again
and inherits the old password. Matches Signal PIN, WhatsApp Chat
Lock, macOS FileVault disable posture.

- New RemovePasswordDialog in SetPasswordDialog.kt: single Current
  password field, reveal toggle, red "Remove" button (colorScheme.error),
  Cancel + Escape dismiss. Auto-focus + Enter submits. Uses the same
  Dialog+Surface+DialogHeader shell as SetPasswordDialog.
- DialogHeader refactored to take a title String (was isChange Bool).
- PrivacyLockSettingsScreen toggle-off path routes through the new
  dialog when a hash exists. Corner case (lockEnabled=true but no
  hash — user manually cleared prefs) still disables directly.
- On successful remove: setLockEnabled(false) + setPasswordHashed(null)
  + "Privacy lock removed" snackbar.
2026-07-01 11:35:52 +03:00
nrobi144 292f8a0c78 feat(privacylock): redesign Set/Change password dialog + snackbar
Rewrites SetPasswordDialog.kt with modern 2024-2026 UX. Adds
"Privacy lock enabled" / "Password updated" confirmation snackbars.

Dialog changes:
- Dialog + Surface(shape=shapes.large, tonal=6.dp) shell instead of
  default AlertDialog. Fixed width 440dp. Matches NewDmDialog.kt.
- Header row with Lock icon + title (titleLarge). Softer body copy
  under the header for the first-time-set path.
- Set-a-password flow uses ONE password field with a reveal toggle
  (Visibility / VisibilityOff, per-field independent). The reveal
  toggle IS the confirmation — no more "confirm password" field.
  Matches WhatsApp Chat Lock + macOS Users & Groups.
- Change-password flow uses two fields (current + new), each with
  its own reveal toggle. Current is verification, not redundancy.
- Real-time checklist row under the New field: green CheckCircle +
  "Min 6 characters" when satisfied, outlined Circle + muted text
  otherwise. Copy-pattern from EditProfileScreen NIP-05 status.
- Save button disabled until the checklist passes.
- Bumps PRIVACY_LOCK_MIN_PASSWORD_LENGTH from 4 to 6.
- Auto-focus first field on open (LaunchedEffect + FocusRequester).
- Enter submits (via onPreviewKeyEvent + KeyboardActions.onDone).
- Escape / click-outside-dismiss are disabled to prevent accidental
  loss of typed password (dismissOnClickOutside = false).
- Wrong-current error shown inline under the Current field.
- All reveal toggles reuse the KeyInputField.kt idiom verbatim.

Snackbar plumbing (scope-local — no CompositionLocal):
- PrivacyLockSettingsScreen owns a SnackbarHostState overlaid at
  Alignment.BottomCenter. LockToggleCard receives an onSaved
  callback, fires "Privacy lock enabled" on first-time set or
  "Password updated" on change.
- DesktopMessagesScreen (banner path) owns its own SnackbarHostState
  overlaid at BottomCenter. MessagesFirstRunBanner takes an
  optional onSaved callback (default {}), fires "Privacy lock
  enabled" after the dialog saves.
2026-07-01 11:35:52 +03:00
nrobi144 d216d22c3e feat(privacylock): Messages first-run discovery banner (Desktop)
Adds an inline banner at the top of the Desktop Messages deck column
that nudges users to enable the privacy lock. Fires only when
!lockEnabled && !firstRunCardSeen; dismissal is sticky across
restarts + lock enable/disable cycles.

- MessagesFirstRunBanner: AnimatedVisibility(expandVertically + fadeIn)
  wrapper around a Surface + Row with a padlock icon, title, body,
  and Enable / Not now buttons. Modeled on OfflineBanner.kt.
- SetPasswordDialog extracted from PrivacyLockSettingsScreen.kt into
  a shared desktop/security/ file so the banner and the settings pane
  both point at the same composable.
- MessagesLockState.onUnlockSuccess() relaxed to accept Disabled as a
  valid previous state, so enabling from the banner keeps the user
  Unlocked and doesn't flash the lock screen. New unit test covers
  this path; all 9 tests green.
- DesktopMessagesScreen wraps its two-pane / compact layout in a
  Column with the banner on top and a Box(weight(1f)) around the
  panes so fillMaxSize propagates correctly.
2026-07-01 11:35:52 +03:00
nrobi144 1c0141aba1 feat(privacylock): Desktop wiring — gate, password unlock, settings
Phase 5 (Desktop-only). Wraps the Messages deck column behind a
PBKDF2-hashed password gate; drops the Android-app slice.

- PrivacyLockSettings gains passwordHashed field + setter (salt$hash,
  base64). Backed by java.util.prefs on desktop.
- PasswordHasher: PBKDF2-HmacSHA256, 100k iterations, 16-byte salt,
  256-bit key, constant-time compare. Same primitive family as
  SecureKeyStorage.
- DesktopMessagesLockGate: synchronous branch select in composition
  (no LaunchedEffect guard) — closes the deep-link race per plan
  §Security Hardening H1. Renders content when Disabled/Unlocked;
  renders inline password TextField when Locked. Fires
  MessagesLockState.onLeaveRoute() in DisposableEffect onDispose so
  navigating away from the Messages column re-locks immediately.
- DesktopMessagesLockGate handles the "no password set" edge case
  with a Disable-lock affordance.
- LocalPrivacyLockSettings CompositionLocal + LocalMessagesLockState
  (from commons) both provided once at the App composition root in
  Main.kt. Constructed with the existing windowScope so the state
  holder's idle timer coroutines are lifecycle-scoped to the Window.
- DeckColumnContainer: DesktopMessagesScreen wrapped in
  DesktopMessagesLockGate for the Messages column.
- Desktop PrivacyLockSettingsScreen: Column + Card layout matching
  LocalRelaySettingsScreen (no Scaffold). Toggle, "Change password"
  affordance with a full set/change dialog (old + new + confirm),
  inactivity timer dropdown (1m / 5m / 15m / 1h / Never), redaction
  level dropdown (Hidden / Full), honest limitations copy. Auto-opens
  the set-password dialog if user toggles ON with no password set.
- Slotted into the existing Settings pane in Main.kt right after
  LocalRelaySettings.
2026-07-01 11:35:52 +03:00
nrobi144 c4f647d01a feat(privacylock): foundation — state holder, settings, gate composable
Phase 1 of the messaging privacy lock. Headless cross-platform spine in
commons; no UI wiring yet.

- LockState sealed interface (Disabled / Locked / Unlocked)
- InactivityTimer enum (1m / 5m / 15m / 1h / Never; default 5m)
- DmRedactionLevel enum (Generic / Full)
- PrivacyLockSettings interface (StateFlows + mutators)
- PreferencesPrivacyLockSettings backed by java.util.prefs in jvmAndroid
  (shared by Desktop + Android; node com/vitorpamplona/amethyst/privacylock)
- MessagesLockState — app-global state holder; initial value seeded
  synchronously from prefs to close the deep-link race; LocalMessagesLockState
  CompositionLocal provided at App root
- CredentialPrompter interface + PromptResult enum +
  LocalCredentialPrompter CompositionLocal
- MessagesLockGate composable — synchronous branch select (no
  LaunchedEffect guard); LockScreen with biometric button
- IdleTimerModifier — pointerInput Initial-pass, non-consuming
- 8 unit tests covering cold-start seed, idle expiry, leave-route,
  Never timer, user-interaction reset, settings cascade,
  credential-unavailable disable. All green.
2026-07-01 11:35:51 +03:00
davotoulaandgithub-actions[bot] dde6616451 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-01 08:27:22 +00:00
davotoula 15338dffcc docs(embed): explain intentionally-empty method bodies for Sonar S1186 2026-07-01 10:11:24 +02:00
davotoula 0adee41711 refactor: extract duplicated string literals into named constants 2026-07-01 09:38:52 +02:00
davotoula c66d1db0de fix(embed): add type test to MagnifierFrame.equals for Sonar S2097 2026-07-01 09:29:36 +02:00
nrobi144 d0646acf3c Merge upstream/main into feat/desktop-hashtag-spam-filter 2026-07-01 09:53:09 +03:00
nrobi144 7a18e30fc4 feat(desktop): hashtag-spam filter with collapse-with-reveal
Damus-inspired content filter that collapses notes abusing `t` hashtag
tags into a compact reveal-on-click placeholder. Ships default ON with a
threshold of 5 (adjustable 1–20 in Settings → Content Filters, or off).

Scope
- Pure check (`HashtagSpamCheck`) + settings interface
  (`HashtagSpamSettings`) live in `commons/moderation/`, callable by
  Desktop, `amy` CLI, and (future) Android.
- JVM-backed `PreferencesHashtagSpamSettings` writes to the shared
  `java.util.prefs` node `com/vitorpamplona/amethyst/filters`, so `amy`
  and Desktop observe the same value automatically.
- `CollapsedSpamNote` placeholder in `commons/ui/note/` takes only
  primitive scalars so Android can adopt it without touching commons.
- Desktop wraps every `NoteCard` call site (FeedNoteCard, QuotedNoteEmbed,
  BookmarksScreen, 5 SearchResultsList sites) with a shared
  `SpamCheckedNoteRender` helper. Thread root notes auto-expand via
  `forceReveal=true`; replies still respect the filter.

Exemptions
- Long-form articles (kind 30023)
- Authors in the follow list plus self
- Repost wrappers check the inner event's tags via precomputed
  `note.replyTo`, falling back to `containedPost()`

Search UX fixes bundled in
- Removed the `#hashtag` → "Direct lookup" card. `QueryParser` already
  extracts `#xxx` into the query's hashtag filter, so typing `#bitcoin`
  now goes straight to filtered results.
- Search-result rows now trigger metadata loading via
  `subscriptionsCoordinator.loadMetadataBatched(authors)` and observe
  each user's metadata flow via a new `rememberDisplayData` helper, so
  display names + avatars refresh when kind-0 arrives from index
  relays. Same helper reused in Bookmarks.

Tests + docs
- 19 unit tests (check × 10, displayed-event unwrap × 4, prefs × 5),
  all green.
- Manual testing sheet with 16 scenarios at
  `desktopApp/plans/2026-06-29-hashtag-spam-filter-manual-testing-sheet.md`.
- Plan at `docs/plans/2026-06-29-feat-desktop-hashtag-spam-filter-plan.md`.
- Cross-client desktop feature backlog reference at
  `desktopApp/plans/_desktop-feature-backlog.md`.
2026-07-01 09:46:43 +03:00
Claude f357b445c3 fix(quartz): serialize PoolRequests state machine to kill shared-sub double-REQ race
A subscription id is driven from two threads at once: the app thread (the
subscribe/unsubscribe path) and every relay's socket-reader thread (an EOSE
that triggers an auto-resend). Both read the subscription state and both can
decide "the filters changed, send a REQ", but the decision (read state) and the
send (mark state SENT in onSent) were not atomic. So the reader could observe
the pre-send state — filters still on the previous value — while the app had
already moved the desired filters forward, and both would send a REQ for the
same sub id.

Two REQs on one id race on the wire: the relay answers with duplicate EOSEs and
events, or — if a CLOSE interleaves — an empty result that silently truncates a
paged download. This is what intermittently broke fetchAllPages on large sets
(fixed at the call site in ed5c25e2 by using a fresh sub id per page); this
commit fixes the underlying race in the relay-client layer, which could equally
corrupt any subscription that spans multiple relays (several reader threads
mutate the same RequestSubscriptionState maps concurrently).

The fix:
- Add a tiny non-reentrant spin lock (withStateLock, same AtomicBoolean
  primitive BasicRelayClient uses) guarding every access to the subscription
  state machine. Listener callbacks and socket sends stay OUTSIDE the lock —
  they re-enter this class via onSent, so holding it across them would deadlock.
- Fold the send decision into decideCommandLocked, which runs under the lock and
  pre-marks the state SENT (+ filters) the moment it decides to send a REQ. A
  concurrent decider then sees SENT/updated filters and declines, so exactly one
  REQ is ever produced.

Verified with a deterministic A/B repro that pins the exact interleaving open:
pre-fix 300/300 episodes produced a duplicate REQ; post-fix 0/300 (max one REQ
per episode). Kept as PoolRequestsConcurrencyTest. Full relay test suite passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 02:30:19 +00:00
dependabot[bot]andGitHub ebc4bee4d9 chore(actions): bump the actions group with 4 updates
Bumps the actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [softprops/action-gh-release](https://github.com/softprops/action-gh-release), [actions/cache](https://github.com/actions/cache) and [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

Updates `softprops/action-gh-release` from 3.0.0 to 3.0.1
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/b4309332981a82ec1c5618f44dd2e27cc8bfbfda...718ea10b132b3b2eba29c1007bb80653f286566b)

Updates `actions/cache` from 5 to 6
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v5...v6)

Updates `peter-evans/create-pull-request` from 7 to 8
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](https://github.com/peter-evans/create-pull-request/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/cache
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 00:45:34 +00:00
Claude ed5c25e2b1 fix(quartz): fresh subId per page in fetchAllPages (was truncating large results)
fetchAllPages reused a single subscription id across all pages
(unsubscribe + immediately re-subscribe the same id). On a real relay that
caps REQ results, the rapid same-id CLOSE→REQ races on the wire: in-flight
events from the previous page's REQ bleed into the next page's listener.
Those stale events carry a created_at above the freshly-lowered `until`, so
`match()` rejects them, the page ends with pageCount == 0, and the whole
loop breaks — silently truncating the download.

Observed against wss://wot.grapevine.network: a full kind:0 download (~3.55M
events, per a concurrent negentropy sync) stopped at 89,500. A controlled
diagnosis paging the same data with a fresh subId per page vs a shared subId
reproduced it exactly: shared stalled at ~95k with in-page duplicates and
events above `until`; fresh advanced cleanly with no duplicates. After the
fix, the real-relay fetchAllPages sails past the old stall (100k+ and
counting).

Fix: allocate the subId inside the paging loop so each page is an
independent subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-07-01 00:42:33 +00:00
Claude e5f43904f2 perf(quartz): stream negentropy sync in bounded memory for huge windows
Rework the negentropy download path from "reconcile fully → then download"
into a single back-pressured streaming pipeline so peak memory is independent
of the window size — built for multi-million-event syncs.

- reconcileStreaming drives the NIP-77 rounds directly (instead of via
  NegentropyManager) and hands each round's ids to a bounded id-queue *before*
  acking the next round, so the relay's id stream is paced to the downloader.
- Ids flow id-queue → bounded download worker pool → bounded delivery channel;
  a slow consumer back-pressures the whole chain. The full id list is never
  materialised.
- Drop the global event-dedup set (was O(set) ~ hundreds of MB at 4M): NIP-77
  yields a distinct id set, so each event is requested once. Keep only a tiny
  per-batch dedup (bounded by fetchBatch) to absorb a relay replaying a REQ.
- Pin the relay with a never-matching keep-alive subscription for the sync's
  duration: a NEG-OPEN isn't a REQ, so during a reconcile round the pool would
  otherwise see the relay as unwanted and disconnect it mid-sync.
- Document that timeoutMs must accommodate the relay's first-frame snapshot
  latency on huge sets (a real strfry took ~73s for an unbounded kind:0 set).

Validated against wss://wot.grapevine.network: streamed 30k kind:0 events with
heap bounded at ~30-90 MB (not growing with the set) and zero duplicates. New
unit test forces many small reconcile frames to exercise the multi-round /
back-pressure path; existing windowing/cap/fallback tests still green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 23:12:21 +00:00
Claude 496cda2f22 feat(podcasts): dedicated Podcast Bookmarks screen + detail-screen toggle
Adds a Podcasts row to the Bookmark lists screen (mirroring Git
Repositories), opening a feed of just the bookmarked podcasts:

- quartz: isPodcastEvent() — a reusable predicate matching shows,
  episodes (NIP-F4 + Podcasting-2.0) and trailers, used to pull the
  podcast subset out of the mixed NIP-51 kind:10003 bookmark list.
- BookmarkPodcastsFeedFilter / ...FeedViewModel / BookmarkedPodcastsScreen
  filter the bookmark list (public + private) down to podcast notes,
  newest first, and render them with the standard podcast cards.
- Route.BookmarkedPodcasts + AppNavigation wiring; a "Podcasts" row with a
  live count in ListOfBookmarkGroupsFeedView.
- The single-podcast detail screen (PodcastScreen) gains a bookmark action
  in its top bar that reflects and toggles bookmarked state (reusing the
  stateful PodcastBookmarkButton). TopBarExtensibleWithBackButton now
  accepts an actions slot to host it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 23:05:29 +00:00
Claude ec3b41aa34 feat(podcasts): pay V4V splits through the zap button as real zaps
The standard zap button now detects a Podcasting-2.0 value-for-value block
on a podcast note (episode or show) and pays that split instead of a plain
author zap — so V4V becomes a first-class zap rather than a separate, no-
receipt payment:

- AccountViewModel.zap() intercepts a note carrying a value block and routes
  the chosen amount to the V4V split. Intercepting at zap() covers every
  entry point (one-tap, amount popup, custom dialog, polls) with no UI churn.
- V4VPaymentHandler gains asZap/zapType: lnaddress shares now attach a NIP-57
  zap request, so a Nostr-aware provider mints a zappable invoice and
  publishes a receipt (driving the zap button's icon/counter). Node shares
  stay keysend — no LNURL, so a receipt is impossible there by protocol.
- Per-minute streaming pays with asZap=false so it doesn't publish a receipt
  every minute.
- The dedicated "Send value" button is now redundant and removed;
  PodcastValueSplits becomes a pure recipient/percentage breakdown display
  next to the zap button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 22:45:48 +00:00
Claude 3fdf418177 feat(quartz): make negentropy paging fallback the caller's choice
Per review: negentropySync should not silently switch transports. Plain
created_at paging is heavier and non-delta, and a caller who reached for
negentropy may prefer to know it failed (try another relay, narrow the
filter, abort) rather than get a surprise paged download.

So:
- negentropySync is now negentropy-only. created_at windowing on the
  relay's max_sync_events cap stays automatic (it's still negentropy), but
  a window that genuinely can't be reconciled — minimal window still over
  the cap, or a relay with no NIP-77 support / disconnect / timeout — now
  throws the typed NegentropySyncException (reason OVER_MAX_SYNC_EVENTS or
  UNAVAILABLE, carrying the failing window) instead of paging. Dropped
  NegentropySyncResult.fellBackToPaging.
- Added negentropySyncOrFetch (+ negentropySyncOrFetchEvents Flow form) as
  the ergonomic "try negentropy, else page" combinator: runs negentropySync
  and, on NegentropySyncException, falls back to fetchAllPages over the same
  filter, deduping by id across both phases and honoring maxEvents. Returns
  NegentropyOrFetchResult so callers can see which path ran and why.

Callers now choose explicitly: negentropySync to handle failure themselves,
negentropySyncOrFetch for automatic paging fallback.

Tests: over-cap relay with spread timestamps succeeds via windowing alone;
over-cap minimal window throws (and the caller can page); orFetch pages on
failure and uses negentropy when it works.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 22:21:02 +00:00
Claude a65e37b8f5 feat(podcasts): render Podcasting-2.0 shows in detail + thread views
Make the single-podcast detail screen spec-neutral so a Podcasting-2.0
show (kind 30078, d=podcast-metadata, e.g. "Soapbox Sessions") renders
its real cover art and title instead of the default Amethyst banner and
"Podcasts" fallback:

- PodcastHeader/PodcastScreen now take a resolved PodcastShow? instead of
  a NIP-F4-only PodcastMetadataEvent?, resolving from either the kind
  10154 (F4) or kind 30078 (P2.0) metadata event.
- FilterOnePodcast adds a #d=podcast-metadata filter on kind 30078 so the
  P2.0 show metadata is actually fetched on deep-links/fresh loads.
- NoteMaster (thread/full view) now dispatches Podcasting20EpisodeEvent,
  Podcasting20TrailerEvent, and the kind 30078 podcast-metadata variant
  to the shared podcast renderers, matching NoteCompose.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 22:05:37 +00:00
Claude 21c714177c refactor(quartz): stream events from the negentropy flow variant
Replace the cumulative `negentropySyncAsFlow(): Flow<List<Event>>` with
`negentropySyncEvents(): Flow<Event>`, which emits each event individually
as it arrives. Rebuilding an ever-growing list per event was O(events²) in
both CPU and memory and pointless for a bulk download; the stream stays
O(1) in memory and hands the caller raw events to collect however they
like.

Events are buffered with Channel.UNLIMITED because negentropySync delivers
through a non-suspending callback — a bounded buffer would drop events when
the collector lags. Callers can apply their own buffer/conflate/
collectLatest downstream.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 22:03:29 +00:00
Claude abfea4e7b6 feat(quartz): add high-level negentropy sync-and-download accessory
Add `INostrClient.negentropySync` / `negentropySyncAsFlow`, a high-level
NIP-77 accessory that downloads every event a relay holds matching a
`Filter` and delivers each (deduped) through `onEvent` — mirroring the
existing `fetchAllPages` accessory so downstream apps stop hand-rolling
the `NegentropyManager` dance.

It encapsulates the parts that make raw negentropy painful:
- reconciles the relay's matched set (empty local set) via NegentropyManager
- downloads the resulting ids through a bounded pool of concurrent REQs
  (`maxConcurrentReqs` subs of `fetchBatch` ids each, refilled on EOSE)
- handles the relay-side cap (strfry `max_sync_events`,
  `NEG-ERR "blocked: too many query results"`) by splitting the filter
  into adaptive created_at windows; a minimal window that still can't
  reconcile (or a relay that doesn't speak NIP-77) falls back to
  `fetchAllPages` and reports it via `NegentropySyncResult.fellBackToPaging`
- caps delivery at `maxEvents`, dedupes through a single consumer, and
  tears down all subscriptions + the neg session on completion/cancel

Scope is controlled entirely by the `Filter` (per maintainer guidance the
caller-supplied local-id delta interface is dropped in favour of a custom
Filter), so the common call is one line.

To drive NEG-OPEN on a single connection, add
`INostrClient.getOrCreateRelay(url)` (default throws; NostrClient delegates
to the pool). Because NEG-OPEN is a one-shot command that — unlike a REQ —
is never replayed on reconnect, the accessory connects and waits for the
relay to be ready before opening the session.

Tests (quartz jvmAndroidTest, in-process relay): full download, maxEvents
cap, clean teardown / no leaked subs, the Flow variant, and window-split +
paging fallback against a relay that rejects the full reconcile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JmSyzdmKyiz3pPxUZ8Mg8Z
2026-06-30 21:48:11 +00:00
Vitor PamplonaandGitHub 0c85986f5b Merge pull request #3428 from vitorpamplona/claude/topnav-mine-filtering-9gbrp3
Centralize "Mine" feed filter to shared TopFilter.Mine flow
2026-06-30 17:37:20 -04:00
Claude 3a36819539 feat: scope "Mine" relay set to outbox + local + private + proxy
Mine should download the user's own events from their outbox (NIP-65 write),
private-storage, local and proxy relays. The previous filterXMine path used
account.outboxRelays, which also included broadcast relays — write-only blast
targets that don't serve reads — so it queried the wrong set.

Add AccountMineRelayState (a sibling of AccountOutboxRelayState with broadcast
swapped for proxy) and feed it into the shared TopFilter.Mine resolver.
MineFeedFlow now pins authors=[me] to that fixed relay union via
AuthorsByProxyTopNavFilter — no per-author outbox resolution needed, since the
only author is the user and their relays are known from account state. Both the
DAL (liveXFollowLists) and the relay sub-assemblers (liveXFollowListsPerRelay)
consume it, and the per-relay flow re-emits when the mine relay set changes.

Add MineFeedFlowTest covering author scoping, the empty-relay case, and
re-emission on relay-set change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
2026-06-30 21:28:11 +00:00
Claude 8032e8fa49 refactor: resolve TopFilter.Mine to a real author filter in the shared flow
The shared FeedTopNavFilterState.loadFlowsFor() mapped TopFilter.Mine to
AllFollowsFeedFlow, so "Mine" silently fell back to all-follows. Every screen
that offered the Mine chip (badges, communities, music tracks/playlists, git
repositories, nApplets, nSites, and the browser app rows) had to special-case
TopFilter.Mine on both the relay sub-assembler and the local DAL / display
filter to scope content to the user.

Introduce MineFeedFlow, which mirrors AllFollowsFeedFlow's outbox/proxy split
but pins the author set to the logged-in user's own pubkey. Because both
liveXFollowLists (DAL) and liveXFollowListsPerRelay (relay) derive from this
flow, the generic author paths now narrow to the user, so the per-screen Mine
branches are pure redundancy and are removed.

This also makes outbox-change invalidation automatic: the Mine relay set now
comes from liveXFollowListsPerRelay, an OutboxLoaderState over the user's own
outbox, so a NIP-65 update re-emits through the existing followsPerRelayFlow
collector without a screen-specific trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWUwciqZiXTkbYVSyiF2P4
2026-06-30 21:01:53 +00:00
Claude 821e9bafdd fix: drop the "Mock Podcast" kind:10154 spam flood before consuming
Someone is flooding thousands of identical mock NIP-F4 show-metadata events.
They share an exact fingerprint — title "Mock Podcast", description and content
both "Headless test feed" — so match that and refuse to cache them.

- PodcastMetadataEvent.isMockSpam() encodes the fingerprint (all three fields
  must match, so a real show sharing one field is never flagged); unit-tested.
- LocalCache's PodcastMetadataEvent consume branch returns without storing when
  it matches, so the spam never reaches the cache, feeds, search, or the merged
  podcast list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 20:50:33 +00:00
Claude 1eeda56b51 feat: add "Mine" to the Podcasts and Podcast Episodes feed filters
The two podcast feeds used kind3GlobalPeopleRoutes, which omits the "Mine"
option — so a creator had no way to filter the feed down to their own published
shows/episodes (and the default follow-list view is usually empty for podcasts,
since NIP-F4 shows are their own keypairs you rarely kind:3-follow).

Add a podcastRoutes catalog in TopNavFilterState that mirrors musicRoutes
(content catalog + Mine + interests + mute list) and point both podcast top bars
at it. "Mine" resolves to an AuthorsTopNavPerRelayFilterSet of the user's own
pubkey, which the podcast SubAssemblyHelper already dispatches through
filterPodcastEventsByAuthors — so selecting it actually fetches the creator's
own catalog, not a dead option.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-30 20:07:54 +00:00
Claude d0f6f03d2c Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:32:50 +00:00
Claude 13d654f6be Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-30 19:23:05 +00:00
Vitor Pamplona e32b3160ae Updating libraries. 2026-06-30 15:21:43 -04:00
Vitor PamplonaandGitHub d1bd8dd3b4 Merge pull request #3427 from vitorpamplona/claude/plans-in-folders-hajl33
docs: add per-module plans indexes and master roll-up
2026-06-30 12:03:06 -04:00
Claude ff50652484 docs: add root PLANS.md master index across all module plan folders
Adds a repo-root cross-module roll-up that stitches the 10 per-folder
plans/ indexes into one view: totals, a per-module status table, and a
"live work" section listing every in-progress / queued / abandoned plan
with links. Shipped plans stay in each folder's archive/ and are linked
via the per-module README.

142 plans: 122 shipped, 9 in-progress, 8 queued, 3 abandoned.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:52:49 +00:00
Claude 6579b5f656 docs: audit, status-stamp, and index all module plans
Audited all 143 plan files across the 10 plans/ folders. Each plan now
carries a Status header (shipped | in-progress | queued | abandoned)
backed by codebase evidence, and every folder has a README.md index
grouping plans by status.

Shipped plans were moved into a per-folder plans/archive/ (via git mv,
history preserved) so each plans/ folder surfaces only live work:

  shipped (archived): 122   in-progress: 8   queued: 7   abandoned: 4

docs/plans/ is the frozen legacy folder; its plans were stamped and
indexed in place (48 of 52 archived) but it remains closed to new plans.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016hpUivtmq4pgzqRbY6MYrA
2026-06-30 15:35:38 +00:00
Vitor PamplonaandGitHub dc47296a5f Merge pull request #3425 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-30 09:13:26 -04:00
vitorpamplonaandgithub-actions[bot] a054215c4f chore: sync Crowdin translations and seed translator npub placeholders 2026-06-30 12:51:31 +00:00
Vitor PamplonaandGitHub 26ac7b7ff0 Merge pull request #3426 from vitorpamplona/claude/connected-apps-permissions-bqion9
Add "Manage permissions" UI for Connected Apps detail screen
2026-06-30 08:49:04 -04:00
Vitor PamplonaandGitHub 609aa1d0fc Merge pull request #3424 from nrobi144/feat/desktop-follow-packs
feat(desktop): Follow Packs (NIP-51 kind 39089) — Discover + follow flow
2026-06-30 08:44:40 -04:00
nrobi144 de2c970e3e feat(desktop): Follow Packs (NIP-51 kind 39089) discovery and follow flow
Adds a Follow Packs experience to Amethyst Desktop:

- New "Discover" sidebar destination with featured-pack hero, hashtag chips
  driven by NIP-12 `t` tags, a 3-up "From the pack" notes feed, and a
  right-rail of mini pack thumbnails.
- New "Follow Packs" launchable column (App Drawer + Discover "Browse all")
  with multi-field search across title, description, creator name/npub,
  and `t` tags.
- Pack detail overlay (read-only) with per-member Follow/Unfollow buttons
  that reflect the live kind-3 state, plus pack-level Follow all /
  Unfollow all with a dedupe-aware confirm dialog ("Follow N new (M
  already followed)").
- Bulk follow / unfollow batched into a single kind-3 publish via new
  `FollowActions.buildUnfollowBatch` and `Kind3FollowListState.follow/
  unfollow(users: List<User>)`. The mutating call sites are Mutex-
  protected against concurrent races.
- naddr → 39089 references in notes render as a rich inline card with
  avatar stack + Follow all CTA. Cache miss triggers a one-shot
  subscription; empty / deleted packs render minimal states.
- Shuffle button rotates both the featured pack and the gallery,
  excluding the last 5 shown.
- Pack image fields render via Coil `AsyncImage` with a deterministic
  gradient fallback.

Protocol additions:
- Quartz: `FollowListEvent.hashtags()` convenience accessor.

Bug fixes wrapped into the feature:
- `DesktopLocalCache.consumeContactList` now also loads the event into
  `addressableNotes` so `Kind3FollowListState.getFollowListEvent()`
  returns the user's actual kind-3. Without this, every bulk follow
  silently replaced (rather than appended to) the contact list.
- Added Material Symbols `Shuffle` codepoint and regenerated the
  bundled subset font (still 432 KB).
2026-06-30 12:05:16 +03:00
Vitor PamplonaandGitHub 07e0cc2989 Merge pull request #3419 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-29 22:28:20 -04:00
Vitor PamplonaandGitHub a0586a8613 Merge pull request #3422 from vitorpamplona/claude/event-tag-parsing-w7x43s
feat(nip89): parse and display app-handler t/i/a/client tags
2026-06-29 22:28:02 -04:00
Claude 58b3e84184 feat(napplet): add "Manage permissions" to app pull-down sheets
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
2026-06-30 01:59:00 +00:00
Claude 3b3d7b8c11 feat(nip89): richer related refs, handles/NIPs bottom sheets
Refine the app-handler card rendering:

- NIP "Implements" bottom sheet now shows a wrapping row of clickable NIP
  chips that open each spec in the browser, instead of a list of raw URLs.
- "Handles" gets the same "+N" overflow -> bottom sheet treatment as
  "Implements", listing every handled kind.
- Related (`a`-tag) references now render as the referenced event's author
  avatar + its real name with a short kind label, since these are vouched
  for by the handler's author. Software Application shows the app name
  (not the package-id d-tag) and is labelled "App"; Git repositories and
  NIP-text events use their name/title, falling back to the d-tag until the
  event loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
2026-06-30 01:41:42 +00:00
vitorpamplonaandgithub-actions[bot] d564dac0eb chore: sync Crowdin translations and seed translator npub placeholders 2026-06-30 01:25:28 +00:00
Vitor PamplonaandGitHub 1183ac7a00 Merge pull request #3421 from vitorpamplona/claude/nip07-permission-screen-bug-f501wz
Fix first-connect dialog suppression to allow retry after cooldown
2026-06-29 21:23:02 -04:00
Vitor PamplonaandGitHub 43a6dbaf44 Merge pull request #3420 from vitorpamplona/claude/browser-console-log-design-rq4x48
Console panel: add visibility toggle, elevation, and state sync
2026-06-29 20:31:07 -04:00
Claude 8b4f5e28f4 fix(napplet): let users re-trigger NIP-07 connect after cancelling
Pressing Back on the "Connect to Nostr" first-connect dialog resolves to
AppConnectResult.Cancelled, which added the app's coordinate to an in-memory
`sessionCancelled` set. That set suppressed every future connect prompt for the
entire broker lifetime, so a later request — e.g. the user re-clicking "login"
in the in-app browser — was silently denied and the dialog never reappeared.
Because a Cancel persists nothing, the app also never showed up in Connected
Apps, leaving the user with nothing to clear to recover.

Replace the permanent suppression with a short, self-clearing cooldown
(`cancelledUntil` map): a Cancel suppresses re-prompts only briefly so the
burst of requests a page/napplet fires on load doesn't relaunch the dialog per
request, while a deliberate retry seconds later prompts again. The clock is
injectable for deterministic tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HDcts4HzwSff4fy6oSVA6D
2026-06-30 00:16:31 +00:00
Claude 7d7789c8c9 fix(browser): console toggle + on-top pull tab in full-screen browser
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
2026-06-30 00:14:19 +00:00
Claude 250f918ebe feat(nip89): parse and display app-handler t/i/a/client tags
NIP-89 handler cards (kind 31990) previously only surfaced the supported
kinds and platform links, and even the platform links were silently dropped
when the link tag had no entity type. This widens both parsing and display:

- Fix PlatformLinkTag.match to accept 2-element link tags (entity type is
  optional per NIP-89), so e.g. NostrHub's `["android", "intent:..."]` links
  are no longer discarded.
- Parse the `i` supported-NIP tags (NostrHub points them at the NIP spec
  markdown files) into a new SupportedNipTag, plus accessors for `t`
  categories, `a` related addresses, and the `client` tag.
- Extend AppDefinitionEvent.build() with categories/supportedNips/
  relatedAddresses/client so creation stays symmetric with parsing.
- Render the new data in RenderAppDefinition: category chips, compact
  tappable rows for related addressable events (source repo, store listing,
  ...), a "via <client>" line, and NIP chips with a "+N" overflow that opens
  a bottom sheet listing every supported NIP linking to its spec.

The deprecated `alt` tag is intentionally not surfaced on the handler card.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FLfM7JUTr2vNiNUi4Ukmqn
2026-06-29 23:57:40 +00:00
Claude 6355784c98 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-29 23:51:54 +00:00
Vitor PamplonaandGitHub 4326d84887 Merge pull request #3415 from vitorpamplona/claude/git-repo-readme-code-tabs-e4uf6c
Add git smart-HTTP browser for NIP-34 repositories
2026-06-29 19:50:24 -04:00
Claude fdcfc05ba2 refactor(git): collapse status/PR-update indexes to map().stateIn
Now that observeEvents re-emits the whole matching list each time,
GitStatusIndex and GitPullRequestUpdateIndex no longer need the imperative
launch/collect-into-MutableStateFlow wrapper carried over from the old
newEventBundles version. Each is now a single observeEvents().map { reduce }
.stateIn(scope, Eagerly, null) — dropping startIfNeeded(), the AtomicBoolean
double-start guard, and the MutableStateFlow/asStateFlow pair.

Eagerly (not WhileSubscribed) is required: callers read .value synchronously
(isClosedOrResolved, the feed filters, the home open-count derivations) and
must not see a stale map when nobody is collecting. All startIfNeeded() call
sites removed; stateIn shares one upstream subscription across collectors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:43:25 +00:00
Claude 9a6c535cbd feat(git): bookmarked repositories as a standalone screen
Replace the third "Repositories" tab on the default bookmark screen with a
dedicated entry on the bookmark-lists screen, mirroring how Pinned Notes
works: a row in ListOfBookmarkGroupsFeedView that opens its own
BookmarkedRepositoriesScreen via the new Route.BookmarkedRepositories.

The row shows the bookmarked-repo count from
gitRepositoryListState.publicRepositoryAddressSet; the screen renders the
BookmarkRepositoriesFeedViewModel feed (moved to a repositories/ package),
invalidates on bookmark changes, and preloads any uncached repo
announcements via the EventFinder. Reverts the tab added to
BookmarkListScreen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:30:40 +00:00
Claude a2f918c1eb feat(git): bookmarked repos tab + observeEvents for GitStatusIndex
- GitStatusIndex now subscribes to a kind-1630..1633-filtered
  LocalCache.observeEvents instead of LocalCache.live.newEventBundles,
  matching the GitPullRequestUpdateIndex change: the indexed observable
  seeds from the cache index and re-emits the full list on each new status
  event, so the manual onStart full-cache scan and per-bundle type
  filtering are gone and the collector just reduces to latest-per-target.
- Bookmark screen gains a third "Repositories" tab listing the user's
  bookmarked (NIP-51 kind 10018) git repositories. New
  BookmarkRepositoriesFeedFilter resolves the public repository address set
  to addressable notes (newest first); the screen invalidates it on
  publicRepositoryAddressSet changes and preloads any uncached repo
  announcements via the EventFinder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:20:19 +00:00
Claude 0d8936e8d3 refactor(git): index PR updates via LocalCache.observeEvents
GitPullRequestUpdateIndex now subscribes to a kind-1619-filtered
LocalCache.observeEvents instead of LocalCache.live.newEventBundles. The
indexed observable seeds its matching set from the cache index (via init())
and re-emits the full list on each new PR update, so the manual onStart
full-cache scan and the per-bundle type filtering over every event of every
kind are both gone. The collector just reduces the list to the
latest-per-parent map. PR updates are rare, so recomputing the whole map per
emission is cheaper than scanning every bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:06:11 +00:00
Claude 2316e72573 fix(git): bookmark feedback, social divider, and disappearing-bar reset
- Top-bar repo bookmark toggle now switches between BookmarkAdd (with +)
  and Bookmark glyphs instead of two identical  glyphs, so the icon
  visibly changes shape (not just tint) when starred/unstarred.
- Add a thin HorizontalDivider after the ReactionsRow on the repo home.
- Code browser: opening/closing a file or changing folders swaps the
  scrollable in place, landing the new view at the top with no scroll
  delta, which left the disappearing top bar stranded at its hidden
  offset over a blank band. Expose the scaffold bar state via
  LocalDisappearingBarState and reset it to visible on each in-place view
  change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 23:01:16 +00:00
Claude f14ddd9e45 fix(git): import KMP Dispatchers.IO in GitRepositoryListState
The commonMain GitRepositoryListState used Dispatchers.IO without importing
the multiplatform kotlinx.coroutines.IO extension, so it resolved to the
JVM-only member and broke the iOS native compile
(:commons:compileKotlinIosSimulatorArm64). Matches BookmarkListState.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 22:44:14 +00:00
Claude 2f9162a96f feat(git): New Issue is a full screen; fix square FAB shape
- Converts the New Issue composer from an AlertDialog to a dedicated screen
  (new Route.GitRepositoryNewIssue + GitNewIssueScreen with its own top bar
  and a Create action). The Issues FAB now navigates to it.
- The extended FAB rendered square because the app theme sets shapes.large
  (the extended-FAB default shape) to 0.dp; pin an explicit RoundedCornerShape.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 22:06:02 +00:00
Claude 3e0c688b8c feat(git): htree open-in-browser fallback; remove maintainers; tighten gaps
- Repos that can't be cloned over http(s) (e.g. Iris's htree://) now show a
  "Hosted externally" notice with an open-in-browser link instead of an empty
  dashboard — on the home, the Code screen, and the feed repo card.
- Removed the "Maintained by" row from the project home.
- Tighter home section spacing (12 → 8dp) and a smaller bottom padding on the
  feed repo card so the last-commit line sits closer to the reaction row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 21:06:24 +00:00
Claude a4bd541522 feat(git): snapshot cache, FAB, disappearing-bar fix, title subtitle, spacing
- Snapshot cache: a process-wide GitRepoSnapshotCache keyed by repo address.
  The browser ViewModel serves an already-fetched default-branch snapshot
  synchronously, so the stats render in share-to-image and don't re-fetch when
  switching screens.
- Issues/PR screens: filter chips now live inside the disappearing top bar
  (via the scaffold's belowBar slot) so they hide with it instead of leaving a
  static black band; the feed uses normal content padding.
- New issue is now an extended FAB on the Issues screen.
- Top bar shows the repo description as a single-line subtitle under the name;
  removed the duplicate description from the home body.
- Tighter spacing: home sections, code header rows (branch row / search /
  breadcrumb).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 20:50:07 +00:00
Claude 76ed4bc685 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-29 20:09:32 +00:00
Claude 2378d70326 Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c 2026-06-29 20:09:01 +00:00
Vitor PamplonaandGitHub 2152d5d5d6 Merge pull request #3416 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-29 15:47:30 -04:00
vitorpamplonaandgithub-actions[bot] 4060bfac39 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-29 19:43:35 +00:00
Vitor PamplonaandGitHub f5879ab107 Merge pull request #3418 from vitorpamplona/claude/cashu-wallet-wizard-0zr280
Add Cashu wallet find-or-create wizard with cross-relay discovery
2026-06-29 15:40:59 -04:00
Claude a687e03461 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt
2026-06-29 19:26:56 +00:00
Claude 124dcafdf9 fix(cashu): re-sign on adopt so a deleted wallet can't be re-deleted
The find-or-create wizard can surface a wallet the user previously
DELETED — a relay that missed the kind:5 still serves the kind:17375 to
the crawl. adoptDiscoveredWallet rebroadcast that event verbatim (same id,
same created_at), which loses to the prior NIP-09 deletion two ways:
DeletionEvent.build emits both an `e` tag (old id) and an `a` tag (the
replaceable 17375:pubkey: address), so relays reject the duplicate id and
re-delete every version with created_at <= the deletion's the moment the
kind:5 propagates back — on relays and in our own LocalCache. The
"reactivated" wallet would then silently vanish.

Adopt now re-signs a FRESH kind:17375 + kind:10019 (via publishWalletEvents)
with the discovered wallet's own mints and P2PK key. A new id escapes the
`e`-tag delete and created_at=now escapes the `a`-tag delete, while the
same key + mints preserve the nutzap address and all recoverable funds
(the NUT-13 seed derives from the key, not the event id). Falls back to a
verbatim rebroadcast only if the wallet can't be decrypted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:18:38 +00:00
Claude 0bfe9d370e fix(cashu): return to the Wallet hub after deleting the wallet
Deleting the Cashu wallet popped back to CashuWalletScreen, which on an
empty wallet auto-launches the find-or-create wizard — so the user was
funneled straight back into creating the wallet they just deleted.

Navigate to the top-level Wallet hub (Route.Wallet) via newStack instead,
which pops the Cashu screens off the back stack so the wizard never
composes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:13:18 +00:00
Claude d2dfd044cf feat(cashu): add "Wallet Created" celebration before the mint picker
In the find-or-create wizard, choosing "Create a new wallet" jumped
straight to the mint manager. Add a short celebratory interstitial first:
an animated check badge springs in (bouncy overshoot) behind an expanding
pulse ring, "Wallet Created" + "Now pick a few mints to host your sats"
fade up, a haptic fires, and a "Pick mints" button continues to the mint
selection.

The screen is purely presentational — the kind:17375 still isn't
published until the user adds a mint on the next screen — so "Pick mints"
uses popUpTo to replace the interstitial in the back stack, avoiding an
awkward return to the celebration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 18:02:31 +00:00
Claude 29303b18c2 feat(git): make recent-activity rows and the last-commit line clickable
- Recent-activity rows navigate to the issue/PR they represent (routeFor).
- The last-commit strip is now tappable: on the home it opens the Code
  screen; on the feed repo card it opens the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 17:50:03 +00:00
Claude 09633abae5 feat(git): home polish + repo-card dashboard in the feed
Home screen:
- Nav cards: tighter vertical spacing; badges now count only OPEN issues/PRs,
  derived from the live GitStatusIndex (started on the home so the split is
  correct without visiting the Issues screen first).
- Moved the reaction row to after the recent-activity pulse.
- Added the standard 3-dot note menu (MoreOptionsButton) to the top bar.

Feed card (RenderGitRepositoryEvent):
- Replaced the web/clone links with the same stat tiles + language bar +
  last-commit strip used on the home, loaded from a lazily-fetched shallow
  snapshot. (Factory made internal so the card can build the browser VM.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 17:45:18 +00:00
Claude 45f36a8159 refactor(git): use the standard ReactionsRow; tighten file rows
- Social bar now renders the app's canonical ReactionsRow (reply, boost,
  like, zap, zapraiser, reaction gallery) instead of a bespoke subset, so
  the repository announcement behaves exactly like any other note.
- Code browser file rows: replace the heavy 32dp boxed icon with a plain
  20dp icon and tighten spacing/padding, reducing the oversized horizontal
  gap before the file name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:56:24 +00:00
Claude f0100a35b3 refactor(git): move repo identity into the top bar title
The owner avatar + project name was duplicated as the home's first content
line and the top-bar title. Consolidates it into the top bar: a small owner
avatar (tappable to the profile) + project name. The home hero now carries
only the description and topic/fork chips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:51:51 +00:00
Claude 52f830dd2e fix(git): make the "Mine" repo filter show the user's own repositories
The repositories feed never special-cased TopFilter.Mine, so it inherited
the shared Mine→all-follows fallback — "Mine" behaved identically to "All
Follows", making both selectors look unresponsive when toggled.

Mirrors the music/badges/communities/nsites pattern:
- GitRepositoriesFeedFilter: when the list is Mine, match repositories
  authored by the logged-in user (feed + applyFilter).
- GitRepositoriesSubAssembler: when the list is Mine, query the user's own
  repositories by author against their outbox relays (new
  filterGitRepositoriesMine), bypassing the follow-list machinery.

"All Follows" already routed through the standard follows filter; it now
visibly differs from "Mine".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 16:10:10 +00:00
Claude 5dc09513f6 refactor: build the hub's REQ inline; round the create-podcast FAB
- FilterMyPodcast now builds its two Filters directly instead of routing through
  the topNav-oriented filterPodcastEventsByAuthors helper: episodes+trailers by
  author, and the kind:30078 show metadata constrained to #d=["podcast-metadata"].
  Same wire output, but the #d constraint and the reason for two filters are now
  visible at the call site rather than hidden behind a shared map parameter.
- The "create a podcast" FAB on the Podcasts feed was using the Material3 default
  shape (a rounded square); set shape = CircleShape to match every other FAB in
  the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-29 16:06:45 +00:00
Claude 39a8b7473e fix(cashu): populate mint suggestions in the create-wallet picker
The mint picker (CashuMintsScreen) already renders NIP-87 directory
suggestions, but in the find-or-create wizard's "create a new wallet"
path they showed up empty while the edit-mints screen had them.

Cause: openMintDirectory() subscribed the directory against a one-shot
snapshot of the account's outbox relays (acc.outboxRelays.flow.value). A
freshly-restored account reaching the create path often still has its
relay lists loading, so the snapshot was empty, hit
CashuMintDirectoryState's empty-relay early-out, and never retried.
Outbox-only relays also don't reliably carry the broad NIP-87 mint
announcements.

Fix: subscribe reactively to the union of the user's OUTBOX relays and
their INDEXER relays. The flow re-subscribes the moment relays arrive,
and indexer relays aggregate NIP-87 mint data and fall back to a curated
default set — so the picker is populated even for a brand-new user with
no wallet and no recommendations of their own yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-29 16:01:55 +00:00
Claude b591984118 refactor(git): unify project home into one dashboard design
Removes the old boxed "Overview" section block (About/Links/Topics/
Maintainers cards) that was stacked on top of the new dashboard, which made
the home read as two designs. Its useful content now lives in the dashboard
language:

- New RepoHero: owner avatar + "name / project" title (GitHub-style), with
  description and topic chips.
- RepoMaintainersRow: a compact maintainer avatar cluster.
- Drops the Links card (clone/web URLs) as low-value.

Deletes GitRepositoryOverview.kt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:45:33 +00:00
Claude d270b6a69f feat(git): make the project-home social row interactive
Replaces the read-only zap/reaction/comment counts with the canonical
ZapReaction / LikeReaction / ReplyReaction actions, so the repository home
now supports one-tap zaps (with the amount dialog), reactions, and replying
to the repo announcement — each with its live counter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:38:37 +00:00
Claude 2ad4af2479 feat(git): rich project-home dashboard (stats, languages, social pulse)
Turns the repository home into a data-rich dashboard combining git facts
with the Nostr social layer:

- Social row: live zap / reaction / comment counts on the repo announcement
  (via observeNoteZaps/Reactions/ReplyCount).
- Stat tiles: branches, tags, file count, and last-updated relative time.
- Language breakdown bar: proportional colored segments computed from the
  snapshot's file tree by extension (new GitRepoSnapshot.walkFileNames()).
- Last-commit strip: tip commit summary, author, time and short SHA, exposed
  up-front via the new GitRepoSnapshot.tipCommit (no extra fetch).
- Nav cards now show open issue / PR counts.
- Recent-activity pulse: newest issues/patches merged from the feeds.

quartz: GitRepoSnapshot gains tipCommit (parsed in open()) and
walkFileNames() to enumerate blob paths for the language bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 15:32:47 +00:00
Claude d2225c73cc feat(git): replace repo tab bar with project home + drill-in screens
Replaces the five-tab repository screen (Readme/Code/Overview/Issues/Patches)
with a scrollable Project Home and three dedicated screens:

- GitRepositoryScreen is now the home: repository facts (from the former
  Overview), Code / Issues / Pull Requests navigation cards, and the README
  rendered inline — all in one scroll.
- New GitRepositoryCode/Issues/Pulls screens, each owning its own
  disappearing top bar, reached via new addressable routes.

This removes tab overflow and the swipe-paging between heavy screens, and
fixes the per-tab header issues structurally: the Code browser's branch/tag
and file-search header now live inside the scroll list, so they track the
disappearing top bar instead of staying pinned below it.

README and Overview were refactored into embeddable, non-scrolling sections
(GitReadmeSection, GitRepositoryOverviewSections) so the home can compose
them in a single scroll container.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 00:15:51 +00:00
Claude 144f543014 fix(git): move browser ViewModel factory app-side for KMP lifecycle
The KMP lifecycle-viewmodel artifact used by commons doesn't expose the
create(Class<T>) ViewModelProvider.Factory override (only the desktop/JVM
target hit this), so the factory now lives in amethyst alongside the
viewModel() call, mirroring the NestViewModel pattern. The commons
ViewModel keeps only platform-agnostic state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-29 00:00:31 +00:00
Claude d6ec458117 fix(git): close Issues/PR feed top gap; add "Mine" repo feed filter
The status feed drew its filter-chip header at the top of the content area
(under the disappearing top bar) while the feed's LazyColumn separately
re-applied the full bar-height inset as content padding, leaving an empty
band above the first item. The header now consumes the scaffold top inset
itself and the inner feed renders with the top inset zeroed, matching the
Code tab's padding pattern.

Also adds a "Mine" option to the git repositories top-nav filter so the
feed can show only the logged-in user's own repositories. The feed side
already resolves TopFilter.Mine generically; this just exposes it via a
dedicated gitRepositoryRoutes catalog (kind3 + Around Me + Global + Mine).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 23:55:35 +00:00
Claude 64567c7056 refactor: move V4VSplitEditorState to commons for cross-front-end reuse
The value-for-value split editor's state holder is pure snapshot state over
quartz types + a commons User — no Account, LocalCache, AccountViewModel, or
Android dependency — so per the commons architecture (state holders belong in
commons, CLI-safe where practical) it moves to commons.podcasts. A future
Desktop/iOS V4V editor can now drive the same state; the editor composable stays
platform-side (it needs AccountViewModel + user search).

This is the only podcast app-layer file that's free of amethyst-only
foundations: the rest of the podcast UI / ViewModels / feed filters /
subscriptions are coupled to AccountViewModel, LocalCache, Account, the
per-user subscription framework, or Android media/upload — the same foundations
every feature in the app shares, none of which live in commons — so they stay in
amethyst (as does, for the same reason, the analogous music composer). The
podcast protocol itself was already fully shared: all 50 quartz podcast files
live in commonMain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 23:01:29 +00:00
Claude 714d202e7d refactor(git): move code-browser ViewModel + syntax highlighter to commons
Relocates the two app-agnostic pieces of the NIP-34 code browser into commons
so the desktop front end can reuse them verbatim:

- GitRepositoryBrowserViewModel (+ GitBrowseState) → commons jvmAndroid
  nip34Git package. Pure StateFlow ViewModel over quartz's GitHttpClient;
  no Android/AccountViewModel/INav dependency.
- CodeHighlighter → commons commonMain nip34Git/ui. Pulls the Apache-2.0
  dev.snipme:highlights dependency into commons commonMain.

Amethyst composables now import both from commons. The screen-level
composables stay app-side, matching the commons convention that shared UI
never takes AccountViewModel/INav.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 22:55:56 +00:00
Claude 31f62a1787 Merge remote-tracking branch 'origin/main' into claude/podcast-event-kinds-merge-vv24gd 2026-06-28 22:29:42 +00:00
Claude 5c29c277c8 Merge remote-tracking branch 'origin/main' into claude/git-repo-readme-code-tabs-e4uf6c 2026-06-28 22:29:16 +00:00
Claude 5aaaa90cd7 feat(git): bookmark repos + label filter & counts on status feed
Adds NIP-51 (kind 10018) repository bookmarking with a star toggle in the
git repository top bar, backed by a new GitRepositoryListState in commons.
Removal rebuilds the public tag set and re-signs so encrypted private
bookmarks are preserved without decryption.

Adds a label-filter chip row and open/closed item counts to the Issues and
Patches & PRs status feeds. The active feed's distinct labels drive the
chips; selecting one filters the rendered list, and a stale selection is
dropped when switching status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:57:31 +00:00
Claude 32ac685387 feat: add Nostr users to V4V splits by search, with avatar + name
The split editor previously only took raw, hand-typed Lightning addresses / node
pubkeys — no Nostr users, no avatars, no search. Bring it up to the Amethyst
standard used by zap-splits.

Adding a recipient now leads with a user search (reusing UserSuggestionState +
ShowUserSuggestionList): type a name or @handle, pick a person, and they're added
rendered with their avatar (BaseUserPicture) and display name (UsernameDisplay),
with their lud16 lightning address resolved automatically at save time. Picking a
user with no Lightning address is rejected with a toast. A manual "Add address"
fallback remains for raw destinations — a node pubkey for keysend, or a non-Nostr
lightning address — which keep the type toggle + text field.

Each recipient still shows its live percentage of the total weight and an
optional fee flag. (Recipients loaded from an existing value block arrive as raw
addresses, since the wire format stores only the Lightning destination.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:56:38 +00:00
Claude cd73020dd8 feat: V4V split editor in the podcast show and episode composers
Lets a creator define their value-for-value splits in-app, closing the
create→get-paid loop: set up recipients on the show (and override per episode),
publish, and listeners' boosts/streams fan out to those destinations.

Adds a reusable V4VSplitEditorState + V4VSplitEditor composable: a card with one
row per recipient (name, Lightning-address vs node/keysend toggle, address,
weight, optional fee) and an "Add recipient" action, showing each recipient's
live percentage of the total weight. toPodcastValue() rebuilds the PodcastValue
on save (null when there are no payable recipients); a loaded block's suggested
amount/currency/enabled are carried through untouched.

Wired into both composers, replacing the previous preserve-only passthrough:
- Show editor: edits the show-level split (kind:30078 value block).
- Episode editor: edits the episode-level override (in More details).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:29:00 +00:00
Claude 402c2fd3b4 feat: commit history in the git repository code browser
Add a History action to the Code tab that shows a git-log-style list of the
branch's recent commits, and opens the diff a commit introduced (vs its first
parent) with the shared diff viewer.

quartz: GitCommit + a commit-object parser (skips multi-line headers like a
signed gpgsig), and GitHttpClient.loadHistory() — a shallow tree:0 fetch
(commits only) walked most-recent-first. The fetch filter is generalized from
a blob:none boolean to a filter spec so tree:0 can be requested. Unit-tested
against a real SSH-signed commit object.

amethyst: GitCommitLog (log list + per-commit diff), a History button on the
repo header, and ViewModel hooks (loadHistory / commitDiff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:27:12 +00:00
Claude 6db98c2a73 feat: back the podcast authoring hub with a dedicated REQ
The "Your podcast" hub previously listed only what happened to be in LocalCache,
so on a fresh install (or before the feed had loaded the creator's events) it
could show an empty or stale catalog.

Add a MyPodcast subscription (mirrors the OnePodcast assembler trio) that, while
the hub is on screen, keeps a REQ open for the creator's OWN Podcasting-2.0
catalog on their outbox relays: the addressable episodes (30054) and trailers
(30055) by author, plus the show-metadata kind:30078 constrained to
#d=["podcast-metadata"] (reusing the existing constant so the overloaded app-data
kind isn't pulled wholesale). Registered in RelaySubscriptionsCoordinator.

The hub now also reacts to LocalCache.live.newEventBundles — when the creator's
own episodes/trailers/metadata arrive over the REQ, the lists refresh in place
rather than only on resume.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 21:17:02 +00:00
Claude 9207209b0b feat: word-level intra-line highlighting in git diffs
Emphasize what changed inside a modified line, not just the whole line.
A new pure IntralineDiff helper pairs each delete with its corresponding
add within a hunk and trims the common prefix/suffix to the differing
middle; GitDiffView layers a stronger add/delete background over just that
character span, on top of the existing syntax highlighting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 21:14:57 +00:00
Claude a44ee6b312 feat: finish git PR/patch review — computed diffs and status actions
Pull requests reference a clone URL + commit rather than embedding a patch,
so their changes were invisible. Now the app computes and renders them.

- quartz: a pure Myers O(ND) line-diff (LineDiff) producing the same
  GitDiffHunk model the embedded-patch parser uses, unit-tested against git
  -U3 output and with a reconstruction property check.
- quartz: GitHttpClient.computeDiff(cloneUrl, head, base?) — fetches both
  commit trees, finds changed files by oid, batch-fetches the differing
  blobs and line-diffs each into a ParsedPatch (base = merge base, or HEAD).
- amethyst: a "View changes" section on the PR card that loads the diff over
  the git client and renders it with the shared GitDiffView.
- amethyst: GitStatusActions generalizes the issue open/close controls to
  issues, patches and PRs — patches/PRs additionally get "Mark merged"
  (GitStatusAppliedEvent). Visible to the author and repo owner/maintainers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:33:31 +00:00
Vitor PamplonaandGitHub 447d48bb83 Merge pull request #3395 from vitorpamplona/claude/keen-dijkstra-tzvqgd
Add Nostr signer permissions & relay auth management UI
2026-06-28 16:30:41 -04:00
Claude 97df84ee65 feat: in-app Android authoring for Podcasting-2.0 podcasts
Adds a full create/edit experience so a creator can publish a podcast from the
phone, authoring as themselves (Podcasting-2.0 model: the account is the creator,
episodes/trailers are addressable and editable in place).

A "Your podcast" hub (mic FAB on the Podcasts feed) shows the creator's show or a
create CTA, the new-episode/new-trailer/edit-show entry points, and lists their
published episodes and trailers (tap an episode to edit). Three composers:

- Episode (kind:30054): cover + audio upload through Blossom/NIP-96 (auto-fills
  duration/title from the picked file's metadata), title, summary, and a
  collapsible "More details" section for season/number, video, transcript,
  chapters, and topics. Create + edit + delete; edits preserve the original
  pubdate and any value-for-value splits.
- Show metadata (kind:30078, d=podcast-metadata): cover + the channel fields,
  categories/funding as comma lists, episodic/serial toggle, and explicit /
  complete / locked switches. One per account, create-or-edit in place; the
  podcast GUID and value block are preserved.
- Trailer (kind:30055): title, a short audio/video clip (upload or URL), season.

The upload + media-probe mechanics are shared across the three composers in
PodcastComposerMedia, mirroring the music-track composer's pattern. Publishing
goes through account.signAndComputeBroadcast so events land in LocalCache and
broadcast to the creator's outbox relays. This pairs the existing CLI
(`amy podcast20`) with a native mobile authoring path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 20:20:24 +00:00
Claude f25138fe7a feat: edit a NIP-34 repository announcement from the repo screen
Add an owner-only Edit action to the git repository screen's top bar that
opens a settings dialog. Because the repository event (kind 30617) is
addressable, saving republishes it under the same d-tag — preserving the
earliest-unique-commit, relays, maintainers and personal-fork flag — while
letting the owner edit the name, description, clone/web URLs and topics.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:14:54 +00:00
Claude cd934fb0ea feat: git code browser branch/tag switch, file search, image preview; issue labels
Code browser:
- Branch & tag switching: the client now exposes all refs from ls-refs;
  a branch/tag picker on the repo header reloads the tree at the chosen
  ref (GitRepositoryBrowserViewModel.switchRef).
- In-tree filename search: a search field filters the whole tree
  (GitRepoSnapshot.searchFiles) and shows matching paths.
- Image preview: png/jpg/gif/webp/bmp blobs render via Coil instead of the
  binary notice.

Issues:
- Labels at creation: the new-issue composer takes a comma/space separated
  label list, written as NIP-34 `t` tags.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 20:12:00 +00:00
Claude 62f8cbe48a fix: resolve nsite icons and titles by trying both napplet and nsite manifest kinds
NSite manifests (kinds 15128/35128) were never found because all coordinate
construction hardcoded napplet kinds (15129/35129). The connected apps list
and detail screen therefore showed no icon or title for nsite entries.

- Add rememberManifestIconModel(author, identifier) in NappletFavoriteIcon.kt:
  tries napplet coord first, falls back to nsite coord. resolveIconBlob already
  handled all four event types — just needed the right coordinate.
- Replace rememberNappletManifest with rememberManifestEvent in
  ConnectedAppsScreen.kt: watches both napplet and nsite notes, returns
  whichever carries an event. NappletAppCard dispatches on the event type
  to extract title/icon from NappletManifest, RootSiteEvent, or NamedSiteEvent.
- ConnectedAppDetailScreen.AppIdentityHeader: swap hardcoded kind+
  rememberNappletIconModel call for rememberManifestIconModel(author, identifier).
- resolveNappletMeta in NappletManifestLookup.kt: widen the cache filter to
  include nsite kinds and dispatch title/icon extraction across all four types.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 20:07:11 +00:00
Claude 95a2d9c44e Revert "feat: let ExoPlayer handle audio focus (pause on calls / other media apps)"
This reverts commit bce50d1d01.
2026-06-28 19:45:41 +00:00
Claude 7f583c3988 feat: surface git pull-request updates (kind 1619) in the repo screen
NIP-34 pull-request update events (kind 1619) revise a PR with a newer
commit / merge base, but the repository screen neither subscribed to them
nor reflected them, so updated PRs looked stale.

- Subscribe to GitPullRequestUpdateEvent.KIND in RepositoryContentKinds so
  updates reach the repo screen.
- Add GitPullRequestUpdateIndex (mirrors GitStatusIndex): tracks the latest
  update per parent PR id from LocalCache, exposed as a StateFlow.
- Fold the latest update into the parent PR card: the current commit, merge
  base and clone URLs now reflect the newest revision, with a "Revised"
  marker on both the PR card and the compact Patches-tab row.

Updates are folded into their parent PR rather than listed as separate rows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 19:44:05 +00:00
Claude bce50d1d01 feat: let ExoPlayer handle audio focus (pause on calls / other media apps)
The shared feed/podcast/music ExoPlayer previously set no audio attributes and
did not handle audio focus, so a phone call or another media app starting would
not pause playback — meaning V4V streaming payments could keep accruing during a
call even though the user wasn't really listening.

Set USAGE_MEDIA audio attributes with handleAudioFocus = true on the pooled
player. ExoPlayer now pauses on focus loss (a call, another app's playback) and
ducks for transient interruptions; pausing flips isPlaying false, so the
streaming-payment accrual stops with it for free.

Tradeoff: in Media3 a muted player still requests focus while playWhenReady is
true, so muted feed autoplay now requests audio focus too. Acceptable for
correct call/interruption behavior; can be scoped to audio-only players later if
it proves disruptive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 19:37:46 +00:00
Claude c464c7cdba feat: replace capability toggle with dialog and fix napplet icon loading
- Capability rows now open a dialog with radio options (Ask each time /
  Always allow / Never allow) instead of the confusing Switch+revoke
  button combo; requiresPerUseConsent capabilities omit "Always allow"
- AppIdentityHeader now reactively loads napplet/nsite icons via
  rememberNappletIconModel using the full kind:pubkey:identifier coord

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 16:22:01 +00:00
Claude dedabe6451 fix: only stream V4V sats while audio is audible, not merely "playing"
isPlaying stays true when the player is muted (the voice player's mute button
sets volume to 0) or when the system media volume is at 0 — so the previous gate
could keep spending sats per minute while the user hears nothing (e.g. they
muted and pocketed the phone). Hitting pause and locking the screen were already
safe (pause flips isPlaying false; a screen-locked podcast that keeps playing is
audible listening), but muting was a real silent-spend hole.

Tighten the per-minute accrual gate to require the audio is genuinely audible:
playing AND no playback error AND in-app controller volume > 0 AND system
STREAM_MUSIC volume > 0. The whole read is guarded, so a released controller or
missing AudioManager resolves to "not audible" and stops accrual.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 16:14:41 +00:00
Claude bee418fd17 fix: clarify capability toggle and revoke button semantics in ConnectedAppDetail
The toggle ON/OFF state was ambiguous (users couldn't tell if OFF meant
"ask me" or "permanently deny"). The revoke/Block icon looked like a deny
action but actually resets to "ask me each time".

- Add "Allow always" (primary) / "Never allow" (error) label above the
  Switch so the toggle's two states are explicit
- Swap MaterialSymbols.Block for MaterialSymbols.Refresh on the reset
  button — Refresh reads as "start over / go back to asking"
- Update its content description to "Ask me each time"
- Rename "Blocked" to "Requires per-use approval" for per-use-consent
  capabilities to explain why there's no toggle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 16:06:36 +00:00
Claude 1fe9bcec68 feat: per-minute V4V streaming payments, gated strictly to real playback
Adds the streaming half of Podcasting-2.0 value-for-value: a "Stream sats"
toggle on the episode player that, while on, pays the value split once per full
minute of playback at a chosen sats/minute rate (boostagram action "stream").

The hard requirement is that it must never pay while the user isn't listening,
so accrual is bound tightly to genuine playback rather than a free-running timer:

- The control lives inside the player composable, so navigating away, scrolling
  it out of a feed, or tearing down the screen disposes it and stops streaming.
- Each second the engine re-reads the live MediaController.isPlaying and only
  accrues when audio is actually playing and there's no playback error. The
  player already pauses itself on background / off-screen / audio-focus loss /
  error, so every one of those halts accrual for free. A released controller
  reads as not-playing (guarded).
- Only whole, actually-played minutes are billed; a partial minute is dropped
  when the session ends (never rounded up). This rule is a pure, unit-tested
  unit (PodcastStreamingAccrual).
- The toggle defaults OFF and uses plain remember (not rememberSaveable), so it
  never silently resumes after a rotation or process death — the user re-opts in.
- Streaming is gated to an in-app wallet (NWC / CLINK debit); we never auto-fire
  an external wallet intent every minute. Per-minute errors are swallowed (no
  toast spam) while one-off boosts still surface errors.

The selected rate is always shown on the toggle and a live "streamed N sats this
session" counter makes the spend visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 16:03:56 +00:00
Claude 39727d819e feat: flagship git review — diff viewer, issue management, polished Issues tab
Build out the repository screen into a project-review hub.

PR/patch diff viewer:
- quartz: UnifiedDiffParser turns a NIP-34 patch (kind 1617) `git
  format-patch` body into a commit message + structured per-file diffs,
  bounding each hunk by its header counts so the mbox "-- " signature is
  never miscounted. Unit-tested against a real git-generated patch.
- amethyst: GitDiffView renders a GitHub-style file-by-file diff —
  stat summary, collapsible file cards, +/- line coloring, old/new line
  numbers, and per-line syntax highlighting reused from the code browser
  (size-guarded). Wired into the patch card; feeds keep the compact preview.

Issue management:
- New-issue composer (subject + body) that builds, signs and broadcasts a
  GitIssueEvent via the account signer; reachable from the Issues tab.
- Author/maintainer Open/Close controls on the issue card, publishing
  GitStatusOpen/Closed events that flow back through GitStatusIndex.

Issues tab polish:
- Open/Closed filter chips, label (#topic) chips on each row.

Adds git_diff_files_changed plural and issue/diff strings. No new icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 16:00:35 +00:00
Claude f502b09b55 feat: rename See more/less to Show/Hide Event and make JSON scrollable
The raw event JSON toggle in the signer consent dialog is now labeled
"Show Event" / "Hide Event" instead of the generic "See more" / "See less".
The expanded JSON block also gains horizontal scroll (softWrap=false +
horizontalScroll) so wide event JSON doesn't get clipped on narrow screens.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:57:52 +00:00
Claude a71ba61370 feat: execute Podcasting-2.0 value-for-value (V4V) Lightning splits
Adds payment execution to the V4V value blocks that were previously
display-only. A "Send value" button on the value card opens the account's
zap-amount picker; choosing an amount fans the weighted shares out to every
recipient, mirroring how a NIP-57 zap-split is paid.

quartz (pure, tested):
- PodcastValue.computeShares() — splits a total across recipients by relative
  weight, honoring `fee` recipients that take their split as a percent off the
  top. Returns PodcastValueShare (recipient + millisats).
- PodcastBoostagram — the satoshis.stream keysend metadata blob carried in TLV
  record 7629169, with the registered field names and unset fields omitted.
- PODCAST_TLV_RECORD / TYPE_NODE / TYPE_LNADDRESS constants.

amethyst:
- V4VPaymentHandler — the execution engine. lnaddress recipients resolve to a
  BOLT-11 via LNURL-pay and pay through the user's default source (NWC, CLINK
  debit, or external wallet intent), same rails as a zap. node recipients pay
  by NWC keysend (pay_keysend) carrying the boostagram TLV plus any per-recipient
  custom TLV; keysend is NWC-only, so node recipients are skipped with a clear
  error when no NWC wallet is configured.
- AccountViewModel.payV4V() wrapper + the "Send value" amount picker on the
  value card, wired for both episode and show value blocks.

V4V recipients are raw Lightning destinations, not Nostr users, so there is no
zap request and no zap receipt — just the payment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 15:43:22 +00:00
Claude 3cc0ce09d4 feat: show website favicon and domain in all browser-connected app views
Web app entries (browser:https://...) now display:
- The captured favicon from BrowserIconRegistry (same source as the
  bottom-nav favourite website icon) in ConnectedAppsScreen,
  ConnectedAppDetailScreen, and all three permission/consent dialogs
- The domain name (host) as the title instead of the full URL, via
  OmniboxInput.hostOf() in loadDetailState, NappletConsentSummary,
  buildSignerConsentInfo, and buildConnectInfo
- A globe icon (FavoriteApp.WebApp) instead of the grid icon
  (FavoriteApp.NostrApp) in all consent dialog headers

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:41:27 +00:00
Claude 059f373ef5 feat: polish git repository README + Code browser UI
Make the repository browser flagship-grade rather than utilitarian:

- Shared status components: spinner-backed loading box and an icon-led
  message box with a styled retry action, replacing plain centered text.
- Code tab: a repo info bar (branch + short-commit chips, item count),
  a scrollable chip breadcrumb, and polished entry rows with tinted icon
  tiles, monospace filenames, folder/file color distinction, a trailing
  chevron on folders, and hairline dividers.
- File viewer: a line-number gutter with horizontally scrollable,
  syntax-highlighted code, a language/path bar with copy-to-clipboard,
  and themed surfaces; richer binary/error states.
- README tab: spinner loading + icon-led empty/error states.

Adds a git_repo_item_count plural and copy/text strings. No new icons.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 15:25:22 +00:00
Claude 6f9bf33595 fix: show globe icon and domain for browser-connected web apps
The permission ledger stores browser-visited origins as `browser:<url>`
where "browser" is a sentinel non-pubkey. All previous code treated every
entry as a napplet, causing NPub.create("browser") to fail and
LocalCache.checkGetOrCreateAddressableNote to return null for every entry
-- so icons and titles never resolved.

Now ConnectedAppCard splits on author == "browser": web-app entries get a
globe icon (FavoriteApp.WebApp), the domain extracted from the URL as
title, and no npub row. Napplet entries (real hex pubkeys) continue using
the reactive manifest lookup. The relay subscription now also filters out
"browser" from the authors set so no invalid pubkey is sent to relays.

ConnectedAppDetailScreen receives the same fix in AppIdentityHeader, using
FavoriteApp.WebApp for browser entries so the globe icon appears there too.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 15:17:04 +00:00
Claude 291fda5728 feat: add README and Code tabs to the git repository screen
Render the repository README in the first tab and add a Code tab that
browses the repo's file tree and renders source files (syntax-highlighted),
reading directly from the NIP-34 clone URL over the git smart-HTTP v2
protocol (works with GRASP/ngit bare servers as well as GitHub/GitLab).

quartz (jvmAndroid): a from-scratch git smart-HTTP v2 client — pkt-line
codec, packfile parser with OFS/REF delta resolution and SHA-1 oids,
tree/commit parsers, and a high-level browser that fetches a shallow
filter=blob:none snapshot (one request for the whole tree) and lazily
pulls file blobs on demand. Offline tests run against real captured
GitHub wire bytes plus a git-generated OFS-delta pack.

amethyst: README tab (rich markdown), Code tab (folders-first browser
with breadcrumb navigation + a file viewer that renders markdown or
syntax-highlighted source), a browser ViewModel, new UI strings, and a
Folder material symbol (font subset regenerated). Syntax highlighting
uses dev.snipme:highlights (Apache-2.0, permissive).

Tabs are now: README, Code, Overview, Issues, Patches & PRs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DpNmN8CvP6HnEsdTGAjVUr
2026-06-28 14:46:32 +00:00
Claude 0d51dff41a feat: live relay subscription for connected-apps manifest screen
Replaces the one-shot fetchAll with a ComposeSubscriptionManager that
holds an open relay subscription to NIP-5D manifests (kinds 15129/35129)
for exactly the set of authors stored in the permission ledger, while
ConnectedAppsScreen is in composition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 14:03:17 +00:00
Claude 5984d20042 feat: verify NIP-F4 podcast authors against their kind:10064 counter-claims
A show's kind:10154 metadata can name any pubkey as an author (host, co-host,
editor) via `p` tags, but those claims are unverified — the show can list
anyone. NIP-F4 lets the named author publish their own kind:10064
AuthoredPodcastsEvent listing the podcasts they actually author, which closes
the loop.

On the single-podcast header, render each claimed author as a row (avatar,
name, role) and cross-check it: the author's 10064 is fetched + observed
lazily via observeNoteEvent, and a "verified" check badge appears only when
that 10064 lists this podcast's pubkey.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 13:48:47 +00:00
Claude 7b2399eb51 feat: inline Podcasting-2.0 chapter list (expand-to-load)
Turn the episode "Chapters" affordance from a link-out into an inline,
timestamped chapter list.

quartz:
- PodcastChapters / PodcastChapter (@Serializable) parse the off-event
  podcast-namespace chapters.json (version + startTime/title/img/url/toc).
  Lenient parse with a malformed-input test.

amethyst:
- PodcastChaptersSection fetches the chapters document with the app's
  preview HTTP client (Tor/proxy aware) off the main thread and renders
  `timestamp — title` rows in a tinted card; empty/failed renders nothing.
- The episode card's "Chapters" chip now toggles this section instead of
  opening the URL. Fetch is lazy — gated behind the toggle — so scrolling a
  feed never triggers network.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 01:38:00 +00:00
Claude 1f5650fe73 feat(amethyst): bookmark podcasts via the existing NIP-51 bookmark list
Rather than build a parallel favorites/subscribe stack for NIP-F4's kind:10054
list, reuse the bookmark list (kind 10003) that already holds multiple kinds via
a/e references — a public bookmark matches the "soft public recommendation"
intent of the favorites list, and the whole chain (Account.addPublicBookmark
branching on addressable vs regular notes, the kind-agnostic Bookmarks feed that
resolves both e- and a-tags) already supports it.

Add a PodcastBookmarkButton toggle and place it in the show and episode card
title rows. Works across all podcast kinds: NIP-F4 shows (10154) / episodes (54)
and Podcasting-2.0 shows (30078) / episodes (30054); bookmarked podcasts then
appear in the standard Bookmarks screen, rendered through the same cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 01:12:56 +00:00
Claude 05b3e3e25e fix: make ConnectedAppsScreen fully reactive for title and icon
The previous approach baked title/iconUrl into ConnectedAppEntry at
load time (a snapshot) and passed pubkey:identifier to
rememberNappletIconModel which expects kind:pubkey:dtag — so both
title and icon never updated from LocalCache.

Now:
- ConnectedAppEntry only holds coordinate + signerPolicy
- Each ConnectedAppCard builds the full kind:pubkey:dtag coordinate
  (kind 15129 for root napplets with empty identifier, 35129 for named)
- rememberNappletManifest observes the live AddressableNote in
  LocalCache so title/iconUrl update reactively as manifests arrive
- rememberNappletIconModel receives the correct full coordinate so
  blossom blob icons load exactly like FavoriteAppsScreen
- The relay fetch now covers all connected apps (not just those with
  missing iconUrl) so manifests arrive promptly

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 01:09:00 +00:00
Claude d90165a4f6 feat: Podcasting-2.0 value-for-value (V4V) splits — parse, display, publish
Make the Podcasting-2.0 `value` block first-class across read and publish. Actual
Lightning execution (keysend to node recipients, LNURL fan-out to lnaddress
recipients, weighted by split) is a separate wallet/NWC effort and is NOT done
here — this lands the data model, display, and authoring.

quartz:
- PodcastValue / PodcastValueRecipient (@Serializable): amount, currency,
  recipients[] (name, type node|lnaddress, address, split weight, fee, custom*).
- Episode `["value", "<json>"]` tag (ValueTag) + accessor/builder; show value is
  parsed from the kind:30078 JSON. Exposed via the shared abstraction as
  PodcastEpisode.episodeValue() and PodcastShow.showValue() (interface defaults,
  so NIP-F4 returns null). Round-trip + JSON-parse tests.

amethyst:
- PodcastValueSplits: a tinted "Value-for-Value" card listing each recipient
  with its address and computed share, rendered on both the episode and show
  cards when a value block is present.

cli:
- `podcast20 episode`/`metadata` gain `--value-json` to publish the block;
  malformed JSON is rejected as bad_args. Verified end-to-end against the CLI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-28 00:35:10 +00:00
Claude e0a6ed5ffc fix: i18n kind names in signer consent; live icons in connected apps list
- Extract kindDisplayName() to KindDisplayName.kt and add kindNameFor()
  helper that picks the translated string resource when available,
  falls back to KindNames English map, then "k<number>"
- NostrSignerOpLabels: use kindNameFor() so "sign for Notes (kind: 1)"
  is translated instead of always English
- ConnectedAppsScreen: wire rememberNappletIconModel so the card icons
  load from blossom just like FavoriteAppsScreen does
- Remove the now-duplicate kindDisplayName() definition from
  RelayInformationScreen.kt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-28 00:01:02 +00:00
Claude e8edd94317 test: verify NIP-22 comments work on podcast episodes; add RootScope marker
Verification of kind:1111 comments on podcast episodes: they already work
end-to-end (parse, build, route to the thread screen, thread assembly, composer,
and the reply/reaction subscriptions all treat any kind as a valid root — nothing
gates on the RootScope marker). Added a quartz test that drives the real
CommentEvent.replyBuilder path and asserts:
- a comment on a Podcasting-2.0 episode (30054) roots on its `a`/`A` address,
- a comment on a NIP-F4 episode (54) roots on its `e`/`E` event id,
- both are kind:1111 and carry the root-kind tag.

Also closes a small consistency gap: every other commentable content type
(articles, all video kinds, pictures, highlights, wiki, polls, …) implements the
RootScope marker, but the podcast events did not. Add it to PodcastEpisodeEvent,
Podcasting20EpisodeEvent and Podcasting20TrailerEvent. Harmless today (no code
does `is RootScope`), but it documents intent and future-proofs any such check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 23:20:18 +00:00
Claude f2fbe260d5 feat: show kind name in signer consent label; fix icon race in ConnectedAppsScreen
- NostrSignerOp.SignKind label now renders "sign for <Kind Name> (kind: N)"
  using KindNames.nameFor(), falling back to "sign kind N event" for unknowns
- Fix ConnectedAppsScreen race: merge two LaunchedEffects into one so
  items is set before observeEvents fires and before the relay fetch runs;
  previously both could find items==null and return early, leaving icons blank
2026-06-27 23:17:56 +00:00
Claude 7fd0741778 fix: center-align text in deny/block OutlinedButtons
Remove the Modifier.fillMaxWidth() + TextAlign.Start that was forcing
button labels to the left edge; buttons now use the default centered layout.
2026-06-27 23:09:31 +00:00
Claude 8573e4fd2f feat(cli): publish Podcasting-2.0 podcasts via amy podcast20
Add a separate command group for authoring the Podcasting-2.0 (podstr) kinds,
kept distinct from the NIP-F4 `podcast` commands because the models differ —
here the logged-in account is the creator and signs everything with its own key,
and episodes/trailers are addressable (d-tag) events.

  amy podcast20 metadata --title T [...]   kind:30078 show metadata (JSON body)
  amy podcast20 episode  --title T --audio URL[,URL] [...]   kind:30054 episode
  amy podcast20 trailer  --title T --url URL [...]           kind:30055 trailer
  amy podcast20 list [USER] [--limit N]    metadata + episodes + trailers

Episodes accept the full rich tag set (video, episode/season, transcript,
chapters, topics, duration); d-tags and the RFC2822 pubdate are auto-generated
when omitted. Thin assembly only — added Podcasting20PodcastMetadata.build() in
quartz so JSON-body construction stays out of cli (covered by a round-trip test).

Verified end-to-end against the running CLI: all three commands build, sign and
emit the expected kinds (30078/30054/30055) with correct d-tags and the --json
single-line contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 23:05:01 +00:00
Claude 546d4ce9c8 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 23:00:26 +00:00
Claude 2e81a71f19 refactor(napplet): replace newEventBundles with LocalCache.observeEvents for manifest updates
LocalCache.observeEvents<Event>(filter) handles both the initial snapshot and
subsequent insertions via the observables registry, replacing the manual
bundle-scan over newEventBundles. Fewer moving parts and the filter is scoped
to manifest kinds rather than scanning every arriving event bundle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude debe3c3a48 feat(napplet): rich app cards with dynamic manifest loading in Connected Apps list
- Add authorPubKey (npub) to ConnectedAppEntry and show it in each card below the
  domain, so users can verify who published the napplet/nsite
- Increase icon to 48dp and use surfaceVariant card background to match the detail
  screen's AppIdentityHeader style
- After the initial cache-based load, subscribe to LocalCache.live.newEventBundles
  and re-resolve metadata (title + icon) for all entries whenever a manifest event
  (kind 15129 / 35129) arrives — handles manifests delivered by any relay subscription
- For apps whose icon is not yet cached, issue a one-shot relay fetch (kinds 15129/35129
  for the relevant author set) via account.client.fetchAll; inject results into
  LocalCache.justConsume so the newEventBundles observer picks them up and updates
  the list without a full reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude 1086f4cf5e fix(napplet): unify DataStore cache key to file path, fixing connected-apps crash
allPolicies() keyed the LargeCache on the filename ("nsp_HASH") while storeFor()
keyed it on the coordinate string. Both point at the same .preferences_pb file,
so getOrCreate created two live DataStore instances for one file —
DataStore's own singleton guard then threw IllegalStateException.

Fix: use file.absolutePath as the cache key in both code paths so the second
call always returns the already-open DataStore instance instead of creating a
new one.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:53 +00:00
Claude 94ac370379 fix(napplet): consistent button styling, fix DataStore crash, icon loading, dead-code cleanup
- Make all four consent actions (Always Allow / Allow Once / Deny Once / Always Deny)
  full-width OutlinedButton/Button so they are visually consistent; remove TextButton
  with left-aligned text that clashed with the centered primary buttons
- Move domain/coordinate label into the header Column directly below the subtitle so
  the URL is contextually grouped with the app identity rather than floating near buttons
- Fix DataStore multiple-instances crash: add nappletPermissionStore and signerPermissionStore
  as lazy singletons in AppModules; warm them up on IO thread to avoid StrictMode
  DiskReadViolation; update NappletBrokerService and connected-apps screens to use the
  shared singletons instead of creating independent instances
- Propagate iconUrl through NappletConsentInfo / NappletSignerConsentInfo / NappletConnectInfo
  data classes; resolve napplet metadata (title + icon) in the broker-side builders
  (NappletConsentSummary, NostrSignerOpLabels) so dialogs receive it ready to display
- Replace manual buildEventJson (org.json) with JacksonMapper.toJsonPretty(EventTemplate)
  in NostrSignerOpLabels; add toJsonPretty(EventTemplate<*>) overload to JacksonMapper
- Delete dead NappletPermissionsScreen.kt and NappletSignerPermissionsScreen.kt (never
  navigated to); remove Route.NappletPermissions and its composable registration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude 840237fa72 fix(napplet): convert capability consent to floating dialog, match consent style
Replace the AlertDialog with the same Surface(extraLarge)/Dialog pattern
used by the signer consent and connect dialogs. Shows app icon + name +
capability category in the centered header, operation detail (with any
content preview) in a selectable surfaceVariant box, and the same button
hierarchy: Always allow (Button, primary) / Allow once (FilledTonalButton)
then Never allow / Not now as left-aligned TextButtons below a divider.
When the capability is per-use only (payments), Allow once is promoted
to the primary Button.
2026-06-27 22:58:52 +00:00
Claude 9dcfc3a0ed fix(napplet): convert connect screen to floating dialog, match consent style
Replace the full-screen semi-transparent overlay with a floating card
Dialog (same Surface/shape/elevation as the signer consent dialog) so
both permission prompts look consistent. Header now shows the app icon,
name, and "wants to connect to your Nostr account" subtitle instead of
the generic "Connect to Nostr" headline. Block button style matches the
deny row in the signer consent.
2026-06-27 22:58:52 +00:00
Claude af2fcc46b6 feat(napplet): promote Always Allow to primary action in signer consent
Move AllowForOp to the primary Button (filled) since always allowing the
operation is the preferred choice, with AllowOnce as the secondary
FilledTonalButton. The time-bound and allow-all options stay in "More
options", keeping the nuclear allow-all less discoverable.
2026-06-27 22:58:52 +00:00
Claude c7566f2685 fix(napplet): center connect dialog, fix label color and domain display
- Wrap content in a scrollable Box with Alignment.Center so the dialog
  is vertically centered instead of stuck at the top
- Explicitly set onSurface color on the PolicyOption label so it stays
  visible regardless of Surface background tint
- Fix buildConnectInfo to show the napplet identifier (e.g. "browser")
  in the block button instead of the raw coordinate prefix (pubkey)
2026-06-27 22:58:52 +00:00
Claude 343263cb2c fix: use CommonsR for napplet_untitled after resource move to commons
The string moved to commons/src/androidMain/res/values/strings.xml
in the upstream merge; update the two remaining call sites.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude d05247541d refactor: extract shared napplet abstractions, remove what-comments
- Extract resolveNappletMeta() to NappletManifestLookup.kt, replacing
  three private copies of the same manifest lookup across
  ConnectedAppsScreen, ConnectedAppDetailScreen, NappletPermissionsScreen,
  and NappletSignerConsentActivity.
- Extract PolicyCard composable to PolicyCard.kt, shared between
  ConnectedAppDetailScreen and RelayAuthSettingsScreen (was duplicated).
- Extract NappletCapability.symbol() to NappletCapabilityExt.kt, shared
  between ConnectedAppDetailScreen and NappletPermissionsScreen.
- Drop what-comments on kind 1/6/7 lines in NostrSignerPermissionLedger.
- Reword TrustedRelayListState stateIn comment to note private-tag absence
  on first boot.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude 330379e53f fix(napplets): address four code-review findings before merge
- Cancel on first-connect dialog now suppresses re-prompting for the
  rest of the session (sessionCancelled set under signerConsentLock)
  instead of showing the dialog on every subsequent request.
- PARANOID signer policy no longer silently bulk-grants ALLOW_ALWAYS
  for all capabilities; the capability ledger is left empty so each
  capability prompts individually, matching user intent.
- NostrSignerOp.Decrypt default changed from ALLOW → ASK in
  reasonableDecision(); the branch is currently unreachable
  (toSignerOp() never produces Decrypt) but ASK is the safer default
  if a decrypt request type is added in future.
- TrustedRelayListState seeds its StateFlow from the synchronously
  available cached relay set, eliminating a startup window where
  IF_IN_MY_LIST incorrectly denied auth to relays in the user's list.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:52 +00:00
Claude 52f1b8723c feat(napplets): require Amethyst consent for all signer types, auto-approve encrypt/decrypt
Remove the signer self-gating bypass that allowed external (Amber/NIP-55)
and remote (NIP-46) signers to skip Amethyst's per-napplet consent UI.
All signer types now go through Amethyst's consent dialogs first; the
external signer then adds its own approval on top (double-prompting).
This lets users differentiate signing requests by app inside the external
signer, since Amethyst itself is the requesting app.

Also expand the REASONABLE policy to auto-approve Encrypt and Decrypt
operations, matching the intent that common/private-key operations that
apps routinely need are pre-approved at the "reasonable" trust level.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 2786c5b683 fix(relayauth): expand IF_IN_MY_LIST to all live relay lists minus blocked
Replace the localRelayServers-only check with account.trustedRelays
(nip65, private outbox, local, dm, search, indexer, proxy, trusted,
broadcast relay lists combined) minus account.blockedRelayList. Uses
normalizeRelayUrlOrNull for consistent URL comparison.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 162ee51955 feat(ui): modernize napplet and relay auth permission screens
- ConnectedAppDetailScreen: replace emoji policy icons (❤👍🕶) with
  themed MaterialSymbols (Favorite/Shield/Lock); show domain instead of
  raw coordinate in AppIdentityHeader
- ConnectedAppsScreen: remove redundant Column wrapper around SuggestionChip
- NappletSignerConsentActivity: add centered FavoriteAppIcon header with
  title + description; promote "Allow once" to FilledTonalButton; collapse
  time-based/granular grants behind a "More options" toggle; resolve app
  icon from cache using coordinate
- RelayAuthSettingsScreen: replace plain radio buttons with styled PolicyCard
  composable (bordered card, icon, check mark); wrap per-relay overrides in
  surfaceVariant Surface with dividers; fix duplicate if/if → if/else;
  add TextOverflow.Ellipsis to URL text

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 9a117d6166 feat(napplets): show app icon in Connected Apps list and detail
Replace the static placeholder glyph with the napplet manifest's
icon() URL loaded via FavoriteAppIcon (Coil AsyncImage + glyph
fallback). Both ConnectedAppsScreen and ConnectedAppDetailScreen now
resolve iconUrl alongside title from the event cache and display the
real app icon when available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude d57c3f71db fix: update quartz auth tests to match new two-arg signWithAllLoggedInUsers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 0e6e7901c2 fix: update KtorRelayTest to match new signWithAllLoggedInUsers signature
The lambda now receives (relay, template) after RelayAuthenticator was
updated to pass the relay URL for per-relay auth policy checks.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 328790ac9f feat: time-bound signer grants, last-used tracking, and relay AUTH settings (NIP-42)
- Add AllowForSession and AllowUntil(expiresAt) signer grant types so users
  can grant temporary access (session, 24h, 30d) from the consent dialog
- Track per-app lastUsed timestamp in NostrSignerPermissionStore and update
  it on every granted signing operation
- Auto-expire timed grants: decide() clears expired op decisions before
  returning, so no background sweep is needed
- Add NappletBroker.sessionAllows in-memory set for session grants (cleared
  on broker destroy, never persisted)
- Implement full NIP-42 relay auth policy system:
  - RelayAuthPolicy enum (ALWAYS / NEVER / IF_IN_MY_LIST) stored in
    AccountSettings and persisted in LocalPreferences
  - RelayAuthDecision (ALLOW / DENY) per-relay overrides in DataStore
  - RelayAuthPermissionLedger combining global policy + per-relay overrides
  - DataStoreRelayAuthPermissionStore writing to relay_auth.preferences_pb
- Wire relay auth into AuthCoordinator: subscribeLedger/unsubscribeLedger
  lets each logged-in account contribute its own policy; signWithAllLoggedInUsers
  now receives the relay URL so it can check the ledger before signing
- Update RelayAuthenticator (quartz) to pass relay URL in the signing lambda
- Add RelayAuthSubscription composable that subscribes both the account and
  its ledger when a screen is active
- Add RelayAuthSettingsScreen: global policy radio picker + per-relay
  override list with toggle and remove; reachable from Settings
- Add Route.RelayAuthSettings, AppNavigation wiring, and SettingsCatalog entry

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude 77dacd9e68 feat: add Connected Apps settings screen with per-app permission detail
Adds a unified "Connected Apps" entry to Account Settings that surfaces
all web apps that have connected to the user's Nostr key (both capability
grants and signer trust levels) in one place.

- Route.ConnectedApps (list) + Route.ConnectedAppDetail(coordinate) (detail)
- ConnectedAppsScreen: merges NappletPermissionLedger + NostrSignerPermissionLedger,
  shows one card per app sorted alphabetically with trust-level chip
- ConnectedAppDetailScreen: identity header, editable trust-level picker
  (Full Trust / Reasonable / Paranoid), op override rows with revoke, capability
  switches, and a "Forget this app" button that clears all permissions
- Settings catalog entry under Account (Apps symbol, keyword-searchable)
- NappletsTopBar Tune icon now navigates to ConnectedApps instead of
  the old capability-only NappletPermissions screen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:51 +00:00
Claude e03fef36e4 refactor: replace hand-rolled JSON builder with org.json.JSONObject
buildEventJson was manually escaping quotes, newlines, and backslashes
which is fragile. org.json.JSONObject.toString(2) handles all escaping
correctly and produces the same pretty-printed output.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Claude e5763b947c fix: polish napplet signer permission UIs
- NappletSignerConsentActivity: replace AlertDialog (broken 5-button
  layout) with a custom Dialog + Surface using heightIn + verticalScroll;
  color-coded allow (primary) / deny (error) action rows; monospace
  "See more" toggle that reveals full raw event JSON with SelectionContainer
  so users can inspect and copy the data being signed/encrypted
- NappletConnectActivity: wrap column in verticalScroll so the trust-level
  options are not clipped on small screens or large font sizes
- NappletSignerPermissionsScreen: show localized op labels ("sign kind 1
  event") and decision labels ("Allow"/"Ask"/"Deny") instead of raw key
  strings and enum names; fix per-op delete touch target to 48dp (M3 min)
- NappletSignerConsentInfo: add rawData field carrying full event JSON for
  sign/encrypt or decrypted plaintext for future decrypt operations
- NostrSignerOpLabels: populate rawData; add buildEventJson helper
- strings: napplet_op_decrypt → "read your private messages" (the consent
  is to expose already-decrypted content, not to perform decryption);
  add napplet_consent_wants_to, see_more/see_less, decision labels

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Claude b48cad67e5 feat: add Nostr signer permission system for napplets/nsites
Implements per-app permission management for the internal nsec signer when
webapps/napplets/nsites connect via Amethyst's built-in key:

- Three trust levels on first connect (FULL_TRUST, REASONABLE, PARANOID)
  with a UI dialog (NappletConnectActivity) matching the design spec
- Per-operation consent dialogs (NappletSignerConsentActivity) for
  sign-kind/encrypt/decrypt with Allow once, Don't ask again, Deny options
- Per-app DataStore storage (DataStoreNostrSignerPermissionStore) using
  SHA-256-hashed filenames so 1000s of apps don't bloat a single file
- NostrSignerPermissionLedger applies policy decisions: REASONABLE
  auto-allows kinds 1/6/7; FULL_TRUST auto-allows all non-payment ops
- NappletBroker extended with first-connect gate and per-op signer gate,
  serialized by a dedicated signerConsentLock mutex
- Permission management screen (NappletSignerPermissionsScreen) to review
  and revoke stored per-app signer permissions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
2026-06-27 22:58:50 +00:00
Vitor PamplonaandClaude Opus 4.8 82f40e0ed1 Merge PR: Fix embedded WebView theming (dark-page corruption + live theme switch)
Merges nostr proposal 81ee3e7c into main:
- fix(napplet): drop algorithmic darkening so dark-by-default sites (e.g.
  ditto.pub) aren't corrupted — it collided with nightThemedContext and inverted
  their nav bars to light. prefers-color-scheme: dark still works without it.
- feat(embed): follow the app theme live in embedded web tabs by rebuilding the
  warm sessions on a DARK/LIGHT flip, instead of requiring an app restart.

Device-verified on ditto.pub + brainstorm.nosfabrica.com, Dark<->Light.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:47:05 -04:00
Vitor PamplonaandClaude Opus 4.8 925c5e454a feat(embed): follow the app theme live in embedded web tabs
An embedded tab's WebView resolves its theme from the context it is built with
(nightThemedContext), once, at construction — a runtime config change does not
re-flip the renderer — so a live app-theme switch never reached an already-warm
surface; it took a full app restart.

Watch the resolved DARK/LIGHT theme and, on a real flip, rebuild the warm
sessions in the new theme:
- EmbeddedTabHost.themeEpoch + rebuildAllForTheme() tears down the warm
  controllers but keeps activeId, so the visible tab re-activates the instant its
  screen re-acquires (no blanked-out surface).
- EmbeddedTabThemeWatcher (mounted by AppNavigation next to the tab layer)
  collects uiPrefs.theme + isSystemInDarkTheme() and triggers the rebuild.
- WebAppScreen / NostrAppScreen key their controller on themeEpoch (NostrApp also
  re-mints its launch params); the preloader re-warms off-screen tabs; the tab
  layer keys each surface on (id, controller) so a rebuilt session gets a fresh
  SandboxedSdkView.

The page reloads in the new theme (unavoidable — the theme is fixed at WebView
construction). Device-verified Dark<->Light with no app restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:43:05 -04:00
Vitor PamplonaandClaude Opus 4.8 c6575a0888 fix(napplet): drop algorithmic darkening so dark-by-default sites aren't corrupted
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>
2026-06-27 18:43:05 -04:00
Claude 88ba824a6e feat: read and surface rich Podcasting-2.0 episode tags
Parse the episode tags podstr emits beyond the basics — video, episode
number, season, transcript and chapters — and surface them in the UI.

quartz:
- Add VideoTag, EpisodeNumberTag, SeasonTag, TranscriptTag, ChaptersTag plus
  accessors and builder DSL on Podcasting20EpisodeEvent.
- Extend PodcastEpisode with episodeVideo / episodeNumber / episodeSeason /
  episodeTranscriptUrl / episodeChaptersUrl as interface defaults, so NIP-F4
  needs no change. PodcastAudio is now documented as audio-or-video media.
- Tests for round-trip + interface access and an all-absent default case.

amethyst:
- Extract shared PodcastBadge / PodcastLinkChip (PodcastChips.kt) and reuse
  them in the show card for visual consistency.
- Episode card: a season/episode badge, a "Video" badge, and Transcript /
  Chapters link chips that open the off-event documents; the player now falls
  back to the video source when an episode ships no audio.
- Compact show-page row: an "S2 · E5" prefix on the date line and the same
  audio→video media fallback.

Read-only. New strings for season/episode, video, transcript and chapters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 22:28:28 +00:00
Claude 7ea8af973d feat: surface richer podcast show metadata with a modern card
Parse and render the Podcasting-2.0 show fields the kind:30078 metadata carries
beyond the basics:

quartz:
- Extend PodcastShow with author, categories, funding URLs, explicit, complete
  and copyright as interface defaults — so NIP-F4 (kind:10154) needs no change
  and just returns empties, while Podcasting-2.0 overrides them.
- Podcasting20PodcastMetadata now parses categories, funding[], copyright,
  type, complete, locked, email and guid from the JSON content (value/V4V is
  still skipped). Covered by expanded tests incl. an all-absent default case.

amethyst:
- Rebuild the podcast metadata card: an author byline, tinted pills for
  Completed / Explicit / genre categories, a prominent filled "Support the show"
  button opening the funding URL, clickable website chips with a globe icon, and
  a subtle copyright footer — all Material3, no new icons/font subset needed.

Read-only. New strings: podcast_explicit/completed/premium/support_show/by_author.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 22:08:03 +00:00
Vitor PamplonaandGitHub 4b6e4be942 Merge pull request #3413 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 17:46:21 -04:00
Claude 76b05bbb85 feat(amethyst): render Podcasting-2.0 trailers (kind:30055)
Trailers were parsed and cached but invisible. Surface them:

- PodcastTrailerListItem: a compact trailer row with a "Trailer" badge (and
  season, when present) that plays the media through the shared episode audio
  player via PodcastAudio. Used both on the show page and as the inline
  renderer (NoteCompose dispatches kind:30055 to it).
- OnePodcastEpisodesFeedFilter now also pulls kind:30055 trailers for the
  show's pubkey; PodcastScreen renders trailer rows distinctly from episodes
  and excludes them from the header's episode count.
- FilterOnePodcast requests the show author's full output — adds kind:30054
  (a pre-existing gap) and kind:30055 alongside the NIP-F4 kinds.

Two new strings (podcast_trailer, podcast_trailer_season). Read-only; trailers
stay scoped to the show page and aren't mixed into the global episodes feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 21:12:47 +00:00
vitorpamplonaandgithub-actions[bot] 9792c5c75e chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 21:03:45 +00:00
Vitor PamplonaandGitHub 6ef7d21132 Merge pull request #3414 from vitorpamplona/claude/search-bar-transparency-75as8b
Add surface background color to SearchBar Column
2026-06-27 17:01:48 -04:00
Vitor PamplonaandClaude Opus 4.8 0e36d416a6 Merge PR: fix(video) — drop errored players from warm pool, crop blurhash to true aspect
Merges nostr proposal 6650c40b into main:
- Square blurhash placeholder: render with ContentScale.Crop when the imeta dim
  is known, so it fills the correctly-sized box instead of letterboxing a
  component-grid-square bitmap inside a taller portrait box.
- "Can't play this video" flash: never return a warm-pooled ExoPlayer carrying a
  stale PlaybackException — releasePlayer drops one that errored before pooling,
  acquirePlayer drops one whose decoder died asynchronously while warm.
- Adds playback-error lifecycle logging used to trace both issues from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:56:34 -04:00
Vitor PamplonaandClaude Opus 4.8 9acb53d574 fix(video): drop errored players from warm pool and crop blurhash to true aspect
Two inline-video bugs surfaced by a portrait Damus post (imeta dim 720x1280, a
rotated H.264 file) that showed a square blurhash in a too-tall box and flashed
"Can't play this video":

- Square blurhash placeholder: the placeholder bitmap is decoded at the
  blurhash's DCT component-grid aspect (e.g. a 5x5 grid -> a square bitmap), not
  the real media shape. When the true ratio is known (from imeta dim) the box is
  already sized correctly, so render the placeholder with ContentScale.Crop to
  fill it instead of letting FillWidth letterbox a square inside the taller
  portrait box.

- "Can't play this video" flash: a warm-pooled ExoPlayer could be handed back
  still carrying a stale PlaybackException. releasePlayer now drops a player that
  errored before being pooled; acquirePlayer drops one whose decoder died
  asynchronously while it sat warm (surface reclaim / codec loss). Either way a
  clean cold/fresh player is used and the stale error never reaches a controller.

Also adds playback-error lifecycle logging (PlaybackError tag with a flattened
cause chain, a live-controller counter, and cold-load + acquire-time
stale-error markers) that made both issues traceable from logcat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 16:53:34 -04:00
Claude 393ff9ba8b fix: make search filter bar opaque
The top bar on the Search screen (search field + filter row with the 3
scope buttons) had no background, so the feed scrolled visibly behind
the segmented buttons and the gaps in the bar.

Apply the theme surface color to the SearchBar Column, before
statusBarsPadding() so the status-bar inset is filled too, matching the
default Material3 top-app-bar container color used elsewhere.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LkfRpnNyeeo3AVEaX71oRX
2026-06-27 20:47:07 +00:00
Claude fc2a6588c2 feat(amethyst): subscribe to Podcasting-2.0 show metadata (kind:30078 #d)
Closes the read path for podstr-style podcast shows so they arrive proactively
instead of only rendering when already cached.

makePodcastsFilter now emits two REQs: the existing NIP-F4 kind:10154 shows plus
a kind:30078 REQ constrained to `#d=["podcast-metadata"]` (the app-data kind is
overloaded, so the d-tag constraint is mandatory). To carry that constraint, an
optional `additionalTags` map is threaded through every topNav podcast
subassembly variant (authors, follows, muted-authors, global, hashtag, geohash,
all-communities, single-community); variants that already pin their own tags
(#t/#g/#a) merge it via the new mergeFilterTags helper. The default is null, so
episode and NIP-F4 REQs are byte-identical to before.

Covered by MergeFilterTagsTest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 20:26:32 +00:00
Claude b840e3496f feat: merge NIP-F4 and Podcasting-2.0 podcast SHOWS into one list
Companion to the episode merge: unify show-level metadata across both drafts.

quartz:
- Add a spec-neutral PodcastShow interface (title/image/description/websites).
- NIP-F4 PodcastMetadataEvent (kind:10154) implements it directly.
- Add Podcasting20PodcastMetadata, a read-only view over a kind:30078 NIP-78
  app-data event with d="podcast-metadata" whose channel fields live in a JSON
  content blob (lenient parse; unknown keys like value/funding/categories are
  ignored). resolvePodcastShow()/isPodcastShowEvent() adapt either kind.

amethyst:
- PodcastsFeedFilter merges kind:10154 with kind:30078 (d="podcast-metadata"),
  both from LocalCache.addressables, gated by isPodcastShowEvent so the 30078
  scan ignores unrelated app-data.
- RenderPodcastMetadata renders via PodcastShow; NoteCompose dispatches a
  podcast-metadata app-data note to it and keeps the text fallback otherwise.

Read support only. The kind:30078 metadata still needs a d-constrained relay
subscription to arrive proactively — that touches the topNav subassembly
plumbing and is left as the remaining step; shows already in cache merge and
render today.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 20:16:38 +00:00
Vitor PamplonaandGitHub 3adce5caf8 Merge pull request #3412 from vitorpamplona/claude/app-ui-theme-customization-evwwkb
Add customizable accent colors, fonts, and font sizes
2026-06-27 16:00:11 -04:00
Vitor PamplonaandGitHub 5f8ce0e692 Merge pull request #3411 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 16:00:03 -04:00
vitorpamplonaandgithub-actions[bot] ac255d5cc6 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 19:59:26 +00:00
Vitor PamplonaandGitHub e81d76e3f3 Merge pull request #3410 from vitorpamplona/claude/nsites-napplets-preload-iwhh1j
Add loading progress bar and error logging to napplet/browser
2026-06-27 15:57:28 -04:00
Claude 373aa7d740 perf: keep ColorScheme.isLight O(1) after accent-color change
isLight fans out to hundreds of themed-color getters on hot note/chat/feed
render paths. The accent-color work had switched it to background.luminance(),
which adds per-call gamma math. Since the accent never touches background
(only primary/secondary) and the dark palette's background is exactly
Color.Black, a single reference comparison is just as accent-robust and
restores the original constant-time cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:56:53 +00:00
Claude bb31f0381b feat(amethyst): surface Podcasting-2.0 episodes in the merged podcast feed
Wire kind:30054 (and kind:30055 trailers) end-to-end so Podcasting-2.0
episodes appear alongside NIP-F4 kind:54 episodes in one list:

- LocalCache consumes both new kinds as addressable replaceables.
- PodcastEpisodesFeedFilter and OnePodcastEpisodesFeedFilter now merge
  kind:54 (LocalCache.notes) with kind:30054 (LocalCache.addressables),
  gating on the shared PodcastEpisode interface.
- The episode renderer, compact list row, and inline audio player read
  through PodcastEpisode / PodcastAudio, so one render path serves both
  kinds; NoteCompose dispatches kind:30054 to it.
- Relay subscriptions request kind:30054 alongside kind:54.

Read support only — Amethyst still publishes NIP-F4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 19:56:34 +00:00
Vitor PamplonaandGitHub 0ed15be975 Merge pull request #3408 from vitorpamplona/claude/nsite-tor-warning-removal-hp52fw
Remove network routing confirmation dialog from napplet host
2026-06-27 15:55:17 -04:00
Vitor PamplonaandGitHub fb357937b8 Merge pull request #3409 from vitorpamplona/claude/default-webclient-list-3sm1ne
Browser: add Discover section with hardcoded web apps & followed nSites/nApplets
2026-06-27 15:53:05 -04:00
Claude 1069fb0701 refactor: move Profile Gallery Style setting to Profile UI settings
The gallery style selector is profile-specific, so it now lives on the
Profile UI settings screen alongside the other profile display toggles
instead of the general Application Preferences screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:50:51 +00:00
Claude bdacc28663 feat: add app UI theme customization (accent color, font, font size)
Adds three new appearance settings to Application Preferences, alongside
the existing Theme selector:

- Accent Color: Purple (default), Blue, Green, Orange, Red, Pink. Drives
  the Material primary/secondary/tertiary colors so buttons, links, FABs
  and switches follow the chosen hue. Purple preserves the original look
  (purple primary + teal secondary).
- Font: System Default, Sans Serif, Serif, Monospace. Applied to the full
  Material typography and to bare Text via LocalTextStyle.
- Font Size: Small, Normal (default), Large, Huge. Scales all text through
  LocalDensity.fontScale without affecting dp-based layout.

Plumbed through the existing UiSettings -> UiSettingsFlow ->
UiSharedPreferences (DataStore) pipeline and the AmethystTheme composable.
New fields default to the current behavior and are appended, so existing
stored settings deserialize unchanged.

ColorScheme.isLight now derives from background luminance instead of a
fixed primary, so the light/dark check keeps working when a non-purple
accent is selected. The primary-derived tint extensions (links, new-item
background, secondary button) now compute from the live scheme so they
track the accent color.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REGsru6cnm6wUzqm12Rh2d
2026-06-27 19:28:18 +00:00
Claude b01e1eb6bc feat(napplet): show top loading bar, keep splash until first paint, log load errors to console
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
2026-06-27 19:20:54 +00:00
Claude fb76705f71 feat: discover followed nsites & napplets in the browser
Add two Discover sections to the browser launcher home that surface the
NIP-5A sites and NIP-5D apps published by the people the user follows,
reusing the same feed + follow-list filter as the dedicated nSites and
nApplets screens (set those to All Follows for a pure follows list).

The launcher subscribes the nsite/napplet assemblers while open, observes
the addressable manifest store, keeps the followed authors' (or own, in
the Mine case), drops ones already pinned, and caps each section. Rows
mirror the web Discover row — manifest icon, title, description, and a
star to pin — launching Nostr-natively through the sandboxed host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 19:10:57 +00:00
Claude 217f4ad9ca fix: drop confirm dialog on nsite Tor network switch
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
2026-06-27 19:06:37 +00:00
Claude 64abf2d544 feat: search the discover list in the omnibox + add 3-dot menu to results
The omnibox now ranks the hardcoded Discover web apps alongside
favorites and history, so typing finds a suggested app even before its
first visit (the ranker dedupes by host, and favorites/history outscore
a plain default, so an already-pinned/visited app never doubles up).

Search results are split into Favorites / Recent / Discover groups using
the visited-URL set so the headers stay accurate, and every result row
now carries the same 3-dot menu as the idle Recent cards: pin/unpin to
favorites, plus remove-from-history for visited sites. The favorite
toggle is shared with the Recent rows via one helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:51:22 +00:00
Vitor PamplonaandGitHub 6dd8771c2a Merge pull request #3407 from vitorpamplona/claude/cashu-icon-alignment-19eflw
Align Cashu icon with reaction gallery icons in Nutzap
2026-06-27 14:50:17 -04:00
Claude 756648aef1 feat: add HiveTalk to discover web apps
HiveTalk (hivetalk.org) — Nostr-native, Lightning-powered video
conferencing. Reachability and own apple-touch-icon verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:44:50 +00:00
Claude 152f81602c fix: align cashu nutzap icon with reaction icons in notifications
The nutzap gallery in MultiSetCompose used WidthAuthorPictureModifier
(55dp, flush-right) for its cashu icon column, while the like/boost
reaction galleries above it use NotificationIconModifier (55dp with a
5dp end inset). That left the cashu glyph sitting ~5dp further right
than the reactions it stacks under. Switch the cashu Box to the same
NotificationIconModifier so the icons line up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011qQdaD3DHRQNDKe2BUnEiM
2026-06-27 18:38:20 +00:00
Claude 3d822dac07 feat(quartz): merge NIP-F4 and Podcasting-2.0 podcast kinds into one episode list
Amethyst's podcast support (NIP-F4) and derekross/podstr use incompatible
identity models: NIP-F4 makes each podcast its own keypair with regular
kind:54 episodes, while the Podcasting-2.0 draft signs editable, addressable
kind:30054 episodes with the human creator's key. The two cannot share a wire
kind, but a client can still render them in one list.

Add the Podcasting-2.0 episode (kind:30054) and trailer (kind:30055) event
classes plus their tags, and introduce a spec-neutral `PodcastEpisode`
abstraction (with `PodcastAudio`) that both kind:54 and kind:30054 implement.
Feeds and UI can now depend on the shared interface and surface both kind sets
in a single, ordered podcast/episode list. NIP-F4 remains Amethyst's publish
format; this only adds read/parse support for the Podcasting-2.0 kinds.

Register both new kinds in EventFactory and KindNames. Covered by round-trip,
spec-example-JSON, and a unified-list test proving both kinds flow through the
shared abstraction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JGa1EM5KWyDo1o5Yr6sS18
2026-06-27 18:38:10 +00:00
Claude 2d6221fd10 feat: show curated descriptions for discover web apps
Render the browser "Discover" section as full-width rows (icon + name +
one-line description) instead of bare icon cells, matching the Recent
row layout which already carries a subtitle. Each suggestion now has a
short curated description (trimmed from the app's own meta description)
so unfamiliar apps explain themselves; tapping a row opens the app, and
a trailing star pins it to favorites.

Auto-pulling page <title>/description was rejected: many of these apps
are client-rendered SPAs that serve an empty <title>, and several titles
are long marketing strings — curated short names read better in the list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:34:53 +00:00
Claude 5029acccee feat: expand suggested web apps list with icons and reachability check
Grow the browser "Discover web apps" list to the full set of
browser-openable Nostr web apps from the nostrapps.com directory plus
several requested additions, and give each entry its own logo.

- Each suggestion now carries iconUrl set to the app's own declared
  apple-touch-icon / icon (PNG or SVG, individually verified to return an
  image), so the grid matches the favicon look of Favorites/Recent
  without any third-party favicon service. Apps whose only icon is an ICO
  (Coil has no ICO decoder) or that couldn't be resolved stay icon-less
  and fall back to the globe glyph until their favicon is captured on
  first visit.
- Added: nymchat, nostr.build, nostrcheck, zap.cooking, x21, divine.video,
  brainstorm, zappix, plektos, zaptrax, zaplytics, podstr, ghostr, mutable,
  metadata, plebsvszombies, blobbi.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 18:12:18 +00:00
Claude f206b0bb46 feat: suggest a default list of Nostr web apps in the empty browser
Add a "Discover web apps" section to the browser launcher home, shown
after the Recent block, with a hardcoded list of popular Nostr web apps
drawn from the nostrapps.com directory. Gives new users (whose Favorites
and Recent are empty) somewhere to start instead of a bare empty screen.

- New DefaultWebClients in commons (URL + label entries), grouped by
  category; extensions/signer-only tools are excluded and every URL is a
  confirmed canonical domain. No remote icons are loaded on the idle
  screen — favicons are captured the normal way once a site is opened.
- Render the list via a new suggestedAppItems grid (long-press offers
  "Add to favorites"); already-favorited apps are filtered out.
- FavoriteAppCell now takes a menu slot so the favorites grid and the
  suggestions grid can offer different long-press actions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0151Uczec41LhTogxkgoAhKa
2026-06-27 17:07:18 +00:00
Claude 53c2dc0f37 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 16:55:49 +00:00
Vitor PamplonaandGitHub 2e47eaf3a6 Merge pull request #3406 from vitorpamplona/claude/git-repositories-feed-7xov6e
Add Git Repositories feed screen with community and author filtering
2026-06-27 12:50:28 -04:00
Vitor PamplonaandGitHub 02aa877f4f Merge pull request #3405 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 12:45:07 -04:00
vitorpamplonaandgithub-actions[bot] fc95f64458 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 16:40:09 +00:00
Vitor PamplonaandGitHub be1b0bb851 Merge pull request #3404 from vitorpamplona/fix/loading-account-ui-thread-stall
fix(perf): unfreeze the cold-start "Loading account" screen in debug builds
2026-06-27 12:37:41 -04:00
Claude a81825b5cd feat: add Git Repositories feed
Add a new top-level feed for NIP-34 Git repository announcements
(kind 30617), mirroring the Pictures/Videos/Workouts feeds.

- GitRepositoriesFeedFilter scans LocalCache addressables for kind 30617
- Full top-nav filter support (follows, authors, global, hashtag,
  geohash, communities, muted) via a per-relay sub-assembler set
- Wired into AccountFeedContentStates, the relay subscription
  coordinator, bottom-bar preloaders, navigation, drawer and the
  persisted per-feed follow-list setting
- Reuses the shared RenderGitRepositoryEvent card via the standard feed
  render path, enriched with topic chips and a personal-fork badge

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GS371vPHy3PhMfQyeAHmZC
2026-06-27 16:27:48 +00:00
Vitor PamplonaandClaude Opus 4.8 981fe528dc chore(debug): add Compose composition tracing deps (debug-only)
Adds androidx.compose.runtime:runtime-tracing and tracing-perfetto so
recompositions show up as named slices in Perfetto system traces — the
tool used to attribute the cold-start feed first-paint cost to specific
composables. debugImplementation only (not shipped); all Apache-2.0.
Usage (runtime-enable broadcast) documented inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:26:45 -04:00
Vitor PamplonaandClaude Opus 4.8 de1362561f fix(perf): collect debug memory snapshot off the main thread
The debug-only MemoryUsageChip ("X/YMB" top-bar indicator, gated on
isDebug) polls collectMemorySnapshot() every 2s from a produceState
block, which runs on the main thread. That reads coil3.disk.DiskLruCache
.size(), a @Synchronized call. On cold start the Coil disk cache holds
that monitor for several seconds (journal init + the burst of image
writes from the initial relay event flood), so the UI thread blocked
inside size() — the "Loading account" frame couldn't repaint until it
returned. Profiling showed a single ~8s render frame and the UI thread
"blocking from coil3.disk.DiskLruCache.size()".

Collect the snapshot via withContext(Dispatchers.IO) so the synchronized
read blocks a background thread instead of the UI. The "Loading account"
stall on cold start drops from ~15-20s to ~5s. Debug-only path, so this
never affected release builds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:26:44 -04:00
Claude 25470e798a Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 14:41:40 +00:00
Vitor PamplonaandGitHub 9d0f444b6f Merge pull request #3403 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-27 08:56:59 -04:00
vitorpamplonaandgithub-actions[bot] 0afb4ada07 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 12:55:24 +00:00
Claude 07963a10b6 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 12:54:01 +00:00
Vitor PamplonaandGitHub 7684c3725f Merge pull request #3400 from roguehashrate/feat/payment-targets
added support for more payment targets
2026-06-27 08:53:33 -04:00
Vitor PamplonaandGitHub 3832f31e52 Merge pull request #3401 from vitorpamplona/fix/video-decode-stall-fallback
fix(video): show browser-fallback overlay when a decoder silently stalls
2026-06-27 08:53:10 -04:00
Vitor PamplonaandGitHub ede3b798a3 Merge pull request #3402 from vitorpamplona/claude/web-app-preload-check-ev4r28
Cache favorited nsite/napplet manifests for offline resolution
2026-06-27 08:52:36 -04:00
Vitor PamplonaandClaude Opus 4.8 c0fadf9473 fix(video): show browser-fallback overlay when a decoder silently stalls
Some codec failures never surface as a PlaybackException: a software HEVC
decoder that can't keep up (e.g. iPhone-recorded hvc1 video on a device
without a HEVC hardware decoder) just parks the player in STATE_BUFFERING
forever — the buffer fills to the LoadControl cap, the playhead never leaves
0, and no error is ever raised. WatchPlaybackErrors only listened for
onPlayerErrorChanged, so the existing RenderPlaybackError "Open in browser"
overlay never showed and the user stared at a blank buffering box.

Add a decode-stall watchdog that polls the controller and synthesizes a
PlaybackException (ERROR_CODE_DECODING_FORMAT_UNSUPPORTED) once the player
sits in STATE_BUFFERING, wanting to play, with >=2s of media buffered ahead
(decoder is fed, not network-starved) yet a frozen playhead for 8s. The
buffer-ahead guard distinguishes a hung decoder from genuine network
starvation, whose buffer is depleted and so is never flagged.

Recovery is automatic: the overlay clears on the STATE_READY transition and,
belt-and-suspenders, the watchdog drops it the instant the playhead advances
again — so a slow device that eventually decodes "just plays." Also narrow
the recovery-clear to STATE_READY only (clearing on STATE_BUFFERING would
wipe the synthetic error instantly) and clear on onMediaItemTransition so a
pooled player starting a new video resets cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 08:49:29 -04:00
roguehashrate cac768175c added support for more payment targets 2026-06-26 21:35:06 -05:00
Claude 7340d93f0c feat: preload and cache pinned nsite/napplet manifests
Pinned web apps in the bottom nav warm reliably because EmbeddedTabFactory
only needs their URL, but a pinned nsite/napplet (FavoriteApp.NostrApp) could
not warm: favorites store only a kind:pubkey:dtag coordinate, and nothing
pulled that addressable manifest into LocalCache until the user opened the
napplet/nsite discovery screen. So embedParams() returned null and the
EmbeddedTabPreloader gave up.

Add FavoriteAppManifestPreloader, mounted once in the logged-in shell
(independent of the API-30 embedded-surface gate, since the full-screen
launcher benefits too). For each NostrApp favorite it drives the existing
EventFinder (via observeNote) to fetch the manifest's coordinate into
LocalCache, so the preloader and launcher can resolve it.

Also cache the resolved manifest event JSON device-locally in
FavoriteAppsRegistry (a second DataStore key, same single-key shape as the
favorites list) and seed LocalCache from it when relays stay silent shortly
after launch, so a pinned nsite/napplet resolves instantly and offline on the
next cold start. The cached copy is re-verified (wasVerified=false) before it
enters the cache, and refreshed whenever a newer manifest arrives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LroBCry1UiXWf9Y4fk4b9h
2026-06-27 01:43:38 +00:00
Vitor PamplonaandGitHub d8ab76d536 Merge pull request #3399 from vitorpamplona/claude/android-accountviewmodel-store-xy21sw
refactor: scope account ViewModelStore with Lifecycle 2.11 rememberViewModelStoreOwner
2026-06-26 21:02:57 -04:00
Claude b3a4e13b85 refactor: scope account ViewModelStore with Lifecycle 2.11 rememberViewModelStoreOwner
The per-account ViewModelStore was managed by a hand-rolled registry
(StoreOwnerRegistry + ScopedViewModelStoreOwner + a RememberObserver) that
tracked configuration changes manually. Its own TODO admitted it could not
clear a store detached around a configuration change, so AccountViewModels
(and their child ViewModels, feed states and relay subscriptions) leaked and
stayed active after switching accounts.

Replace the whole registry with androidx.lifecycle 2.11's
rememberViewModelStoreOwner (already on the classpath at 2.11.0). The owner is
keyed by the account public key via key(): while an account stays logged in
the owner survives recompositions and configuration changes (it is parented to
the Activity's LocalViewModelStoreOwner); when the account changes the previous
owner leaves the composition and its ViewModelStore is cleared immediately.

Deletes ~95 lines of lifecycle plumbing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154te1AiD1Ykz1HCa8ao2Vo
2026-06-27 00:50:32 +00:00
Claude 45de9b1b69 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-27 00:42:20 +00:00
Vitor PamplonaandGitHub 23a1c8af13 Merge pull request #3398 from vitorpamplona/claude/fix-remember-feed-content-padding-7ipbs4
Move rememberFeedContentPadding to commons.ui.layouts
2026-06-26 20:41:33 -04:00
Claude 1c11e5463d feat(cashu): polish wizard UI with motion and a recovery payoff
The wizard was functional but plainer than the rest of the app (which
uses motion APIs in ~88 files). Add tasteful, codebase-consistent polish
to the find-my-funds flow:

- Determinate scan progress: the relay crawl (which can sweep hundreds
  of relays) now shows a LinearProgressIndicator that fills as relays
  complete, plus a count that ticks up — instead of a bare spinner that
  read as "hung".
- AnimatedContent phase transitions: Crawling → Analyzing → result
  cross-fade/slide rather than snapping. Keyed on phase type so the
  in-phase counter updates don't re-trigger the animation.
- Count-up balance reveal: "recoverable" and "Recovered N sats" figures
  animate up from zero the first time they appear — the payoff of the
  flow lands instead of jumping in.
- Haptic on success: a LongPress haptic fires when a wallet is adopted
  or funds are recovered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-27 00:41:11 +00:00
Claude a101f53935 fix: correct rememberFeedContentPadding import path in GitItemListRow
The import referenced the non-existent
com.vitorpamplona.amethyst.ui.layouts package. The function lives in
commons under com.vitorpamplona.amethyst.commons.ui.layouts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q1LfVV9ZRuktCa7XQTzsLe
2026-06-27 00:40:18 +00:00
Vitor PamplonaandGitHub 8607a44ebd Merge pull request #3390 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-26 20:37:33 -04:00
Vitor PamplonaandGitHub 1fc98e0687 Merge pull request #3396 from vitorpamplona/claude/reproducible-build-dsigxh
Make Arti native library builds reproducible
2026-06-26 20:37:15 -04:00
Claude 6bb2f8045d build: pre-merge audit fixes for reproducibility work
Bugs / inconsistencies found while reviewing the branch for merge:

- dependenciesInfo comment falsely claimed Play "still derives this data
  server-side, nothing is lost." Not true: includeInBundle=false means the
  .aab carries no dependency metadata, so Play Console's dependency-insights /
  SDK-vulnerability alerts go unpopulated (uploads still succeed). Corrected
  the comment and the BUILDING.md framing (it called the blob "the one
  remaining blocker" when the Arti .so was the bigger one).

- Version-bump workflow was broken: the README told you to run
  `build-arti.sh --clean` to refresh Cargo.lock, but the build is now --locked
  (fails on a stale lock) and the clone moved to the canonical /tmp path. Added
  a dedicated `--regen-lock` mode (clone + cargo generate-lockfile, no NDK
  needed) and pointed the docs at it. Verified it reproduces the committed lock
  byte-for-byte.

- verify-reproducible.sh: new helper that builds twice and diffs to prove
  byte-for-byte reproducibility; uses portable sha256 (sha256sum/shasum) and
  plain `sort` so it runs on macOS too.

- README verify recipe referenced paths that only resolved from the repo root
  while telling you to cd into tools/arti-build — replaced with the helper.

- rust-toolchain.toml listed four Android targets but only two ABIs ship a
  .so; trimmed to match (check_prerequisites adds any other on the fly).

- BUILDING.md: documented that the bundled Arti .so is reproducible-from-source
  and that secp256k1/webrtc are version-pinned Maven prebuilts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-27 00:19:26 +00:00
vitorpamplonaandgithub-actions[bot] cc72e8045a chore: sync Crowdin translations and seed translator npub placeholders 2026-06-27 00:14:28 +00:00
Vitor PamplonaandGitHub ceca5d52ae Merge pull request #3394 from vitorpamplona/claude/composable-memory-ci-build-jxpwkv
Move UI components to commons module for better code sharing
2026-06-26 20:12:09 -04:00
Claude 94677d1677 refactor: extract self-contained layout/preview leaves to :commons
Continues moving genuinely platform-agnostic leaves out of the :amethyst app
module so they compile once in :commons instead of across all six app variants.

Moved (no Android coupling, no foundation deps):
  - ui/layouts/DisappearingBarState, DisappearingBarNestedScroll, PaddingMerge
    -> commons commonMain (com.vitorpamplona.amethyst.commons.ui.layouts)
  - ui/components/UrlPreviewState
    -> commons jvmAndroid (it references commons.preview.UrlInfoItem, which
       lives in the jvmAndroid source set)

Consumers (incl. the existing DisappearingBar*Test unit tests, which stay in
:amethyst and now import from commons) updated to the new packages. No behavior
change.

Verified: :commons, :amethyst compilePlayDebugKotlin + compilePlayDebugUnitTest,
and :desktopApp:compileKotlin build clean; spotless applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNKcfjNszUZPQShYJjfmnf
2026-06-27 00:06:52 +00:00
Vitor PamplonaandGitHub 2dc80f899e Merge pull request #3393 from vitorpamplona/claude/repo-tabs-status-split-p2ducw
Add Open/Closed filter to Git repo Issues and Patches tabs
2026-06-26 19:40:59 -04:00
Claude ed3a893d9d build: build Arti at a canonical path for cross-environment reproducibility
Empirical finding: with the toolchain pin, locked deps, and
--remap-path-prefix all in place, two host builds of libarti_android.so at
the *same* path are byte-for-byte identical, but two builds at *different*
paths still differ — not in any embedded string (no path leaks into the
binary) but in the order rustc lays out functions/data, which it derives
from the real on-disk artifact paths. --remap-path-prefix only rewrites
embedded strings, not that internal ordering.

So compile in a fixed location (/tmp/amethyst-arti-build, overridable via
ARTI_REPRO_DIR) in both build-arti.sh and build-arti-host.sh. Any checkout
then produces matching bytes, which is what lets F-Droid / a verifier build
at the same canonical path and reproduce the shipped .so. This mirrors how
Rust libraries are reproduced elsewhere (F-Droid builds Rust at a fixed
path too).

Corrects the README, which previously implied path remapping alone gave
path-independent output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:40:18 +00:00
Vitor PamplonaandClaude Opus 4.8 13892db3ae Merge PR: fix(account): adding a read-only npub no longer clobbers a signing account
Merges nostr proposal e283c388 into main (replaces the closed e05208d9 with a
minimal guard). Adding/scanning your own read-only npub for a pubkey you already
hold the nsec for no longer downgrades the signing account — on Android it
wiped cached lists + disabled notifications, on desktop it orphaned the key.
Guarded at the single persistence point on each platform; desktop regression
test verified failing without the guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:24:01 -04:00
Claude f7a15a8407 build: make the Arti (Tor) native build reproducible
The libarti_android.so shipped in the APK is the one binary we compile
ourselves, and it was the remaining blocker to a verifiable build: a Rust
cdylib is only reproducible when the compiler, the dependency graph, and the
embedded build paths are all pinned. None were.

Pin all three:
- rust-toolchain.toml pins rustc (rustup auto-installs it + the Android
  targets), so codegen is stable across machines.
- Cargo.lock is now generated and committed (501 packages); both build
  scripts run `cargo --locked` so transitive versions can't drift.
- repro-env.sh (sourced by build-arti.sh and build-arti-host.sh) rewrites
  host-specific absolute paths with --remap-path-prefix, disables incremental
  compilation, and sets a fixed SOURCE_DATE_EPOCH derived from the Arti tag.

With these, an independent rebuild of the pinned tag reproduces the committed
.so bit-for-bit, which is what lets F-Droid / Zapstore verify it from source
instead of trusting a prebuilt blob. README documents the pins and a
two-path build-and-diff verification recipe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 23:01:48 +00:00
Vitor PamplonaandClaude Opus 4.8 0d0aba90c9 fix(account): don't let adding a read-only npub clobber an existing signing account
Accounts dedup by npub (= pubkey), so adding/scanning your own read-only npub for
a pubkey you already hold the nsec for would overwrite the signing account:
- Android: setDefaultAccount rewrote the per-npub file from fresh read-only
  settings — wiping the account's cached follow/relay/mute lists and flipping
  hasPrivKey off, which silently disables its push notifications (every
  notification path early-returns on !hasPrivKey). The account looked lost ("can't
  post anymore") even though the nsec survived on disk.
- Desktop: saveCurrentAccount overwrote signerType to ViewOnly, orphaning the
  stored key and routing every later switch through loadReadOnlyAccount.

Guard the downgrade at the single persistence point on each platform: when the
account being made current is read-only and a SIGNING account already exists for
the same pubkey, keep the signing account and switch to it instead. A signing
account already subsumes a read-only one, so this loses nothing.

- Android LocalPreferences.setDefaultAccount now returns the settings that
  actually became current; AccountSessionManager.loginAndStartUI shows that.
- Desktop AccountManager.saveCurrentAccount reuses switchAccount() to reload the
  signing account. Covered by a regression test (verified failing without the
  guard).

This replaces the closed proposal e05208d9, which solved the same underlying bug
with a much heavier accountId rework (separate npub/nsec switcher entries — a
niche feature) that itself shipped a logout(deleteKey=true) data-loss bug.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 19:00:51 -04:00
Claude a8db6674c4 feat: compact Issue/PR list rows on the git repository screen
Render the repository screen's Issues and Patches & PRs tabs with a
lightweight one-line-per-item row (author picture, name, NIP-05, subject,
time, status pill and the shared 3-dot options) instead of the full
NoteCompose renderer, which is tuned for items shown inside a regular
feed. Injected via RefresheableFeedView's existing onLoaded slot, so the
feed filters and status-split view models are untouched.

The rows reuse and re-layer the same gating NoteCompose applies — event
loading (WatchNoteEvent), mute/block/report hiding
(CheckHiddenFeedWatchBlockAndReport) and the long-press quick-action menu
— so blocked authors and reported items are hidden here exactly as
elsewhere. No body or media is rendered, so sensitive content never
reaches this list.

Adds GitPatchEvent.subject(), which parses the patch title from the
git-format-patch Subject header (stripping the [PATCH n/m] prefix and
unfolding continuation lines), with unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
2026-06-26 22:55:46 +00:00
Vitor PamplonaandClaude Opus 4.8 2ee0d91b71 Merge PR: feat(cli): amy namecoin resolve + servers verbs
Merges nostr proposal 488e8447 (v2) into main: adds `amy namecoin resolve`
+ `amy namecoin servers` (stateless ElectrumX Namecoin resolution over the
quartz NamecoinNameResolver). The v2 revision fixes the `--server` override to
reuse the shared NamecoinSettings.parseServerString so it keeps
usePinnedTrustStore=true (self-signed Namecoin servers otherwise fail TLS),
plus strict --timeout parsing and accurate docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:31:42 -04:00
Claude 8b74a3e24e refactor: extract 7 pure leaf composables from :amethyst to :commons
First Tier A slice of the UI/components extraction. Moves the genuinely
platform-agnostic leaf composables — those with zero :amethyst
dependencies and no Android coupling — from the app module into the
shared :commons KMP module (commonMain):

  ClickableTexts, ForwardingPainter, GenericLoadable, GlowingCard,
  LoadingAnimation, TranslationConfig, ZonedSwipeModifier  (~616 LOC)

These now live under com.vitorpamplona.amethyst.commons.ui.components and
compile once in :commons (cacheable, incremental) instead of being part
of every one of the six :amethyst variant compilations (play/fdroid ×
debug/release/benchmark). That shrinks the app-module Kotlin compilation
unit — the root cause of the CI Kotlin-daemon OOM — rather than renting
headroom with heap flags.

Consumers updated to import from the new package; no behavior change.
Verified: :commons, :amethyst compile{Play,Fdroid}DebugKotlin, and
:desktopApp:compileKotlin all build clean; spotless applied.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNKcfjNszUZPQShYJjfmnf
2026-06-26 22:29:37 +00:00
Vitor PamplonaandClaude Opus 4.8 112ef0536a fix(cli): --server reuses NamecoinSettings.parseServerString (keeps pinned trust store)
`amy namecoin resolve --server` hand-rolled its own ElectrumX server-string
parser that constructed `ElectrumxServer(host, port, useSsl)` and left
`usePinnedTrustStore` at its `false` default. The Namecoin ElectrumX servers
use self-signed certs, so a TLS connection with the default system trust
manager fails the handshake — meaning `--server electrumx.testls.space:50002`
could not connect even though that exact host resolves fine via the default
list. It also duplicated logic already in `commons`, violating the cli
thin-assembly-layer rule.

Delegate each comma-separated entry to the shared
`NamecoinSettings.parseServerString` (the same parser the Android/Desktop
Settings use), so the CLI inherits both the `host:port[:tcp]` syntax and
`usePinnedTrustStore = true`. The README claim that it "reuses the same …
pinned trust store as the apps" is now actually true for `--server` overrides.

Also: reject a non-integer `--timeout` as bad_args instead of silently
falling back to the default, and document exit code 2 + the `host:port[:tcp]`
syntax accurately.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:27:32 -04:00
mstrofnoneandVitor Pamplona 305d6bc733 feat(cli): amy namecoin resolve + servers verbs
Add Namecoin NIP-05 resolution to the amy CLI as a stateless verb
group, matching the Android and Desktop apps' resolution surface.

  amy namecoin resolve IDENT [--server URL[,URL]] [--timeout SECS]
  amy namecoin servers

IDENT accepts the same shapes the apps accept: raw `d/` / `id/`
names, bare `.bit` domains, and `alice@example.bit` NIP-05-style
local-parts. Output is the resolved Nostr pubkey + relay list (+ the
resolved Namecoin name + matched local-part) as machine-readable
JSON (with `--json`) or human-readable text.

The verb is stateless — no account, no `~/.amy/`, no relays — so it
dispatches alongside `decode`/`encode`/`verify`/`nip`/`kind` before
account resolution and the secret store.

Zero new logic in cli/: the implementation is a thin command-file
wrapper around quartz's existing `NamecoinNameResolver` +
`ElectrumXClient` + the canonical `DEFAULT_ELECTRUMX_SERVERS` set
the apps already ship with, including the pinned trust store for
the self-signed Namecoin ElectrumX ecosystem.

amy is headless so no UI piece is wired in. The `--server` flag
accepts `host`, `host:port`, `tcp://`, `tls://`, `ssl://` per entry
(defaults to TLS on 50002); empty / malformed entries fail with
`bad_args` rather than silently using the default set, so a fat-
fingered override can't go unnoticed.

Outcomes from `NamecoinResolveOutcome` map to amy error codes:
  Success           -> emit JSON, exit 0
  NameNotFound      -> error not_found
  NoNostrField      -> error no_nostr_field
  MalformedRecord   -> error malformed_record (+ namecoin_name extra)
  ServersUnreachable-> error servers_unreachable
  InvalidIdentifier -> error invalid_identifier
  Timeout           -> error timeout

Smoke-tested end-to-end on macOS arm64 against the live ElectrumX
fleet:

  $ amy --json namecoin resolve d/testls
  {"identifier":"d/testls","namecoin_name":"d/testls",
   "local_part":"_","pubkey":"460c25e6…","relays":[]}

  $ amy namecoin servers
  count:   6
  servers:
    - host: electrumx.testls.space
      port: 50002
      tls:  yes
    …

No new runtime deps. The "no Compose UI in the amy image" CI
assertion still passes — `NamecoinNameResolver` + `ElectrumXClient`
are pure JVM (kotlinx.coroutines + kotlinx.serialization, both
already on the CLI classpath via :quartz).

Tests: the resolver, ElectrumX client, identifier parser, and the
default server set already have JVM tests under
`quartz/src/jvmTest/.../namecoin/` — no new core code in this PR,
so the existing coverage applies. CLI verbs are exercised via the
shell harnesses in `cli/tests/`; a Namecoin harness fits the same
pattern but isn't included here.

Parity matrix in `cli/ROADMAP.md` flags `name_history` and the
Namecoin Core JSON-RPC backend as pending separate PRs — both
already exist on Android and Desktop but aren't on upstream main
yet (open PRs against this repo carry them).
2026-06-26 18:23:13 -04:00
Claude b9fa242e68 feat: split git repo Issues and Patches & PRs tabs by status
Within the repository route's Issues and Patches & PRs tabs, add an
Open / Closed & Resolved segmented selector so items are partitioned by
their latest NIP-34 status. Open covers no-status, open (1630) and draft
(1633); Closed & Resolved covers closed (1632) and applied/merged (1631).

The feed filters now take a showClosed flag and consult GitStatusIndex.
Because a status event (kinds 1630-1633) doesn't mutate the issue/patch
note, the additive feed update can't move an item between buckets on its
own, so each view model watches GitStatusIndex.latestByTarget and forces
a full re-partition whenever it changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gs6pxiq58X18Fkz9wdZhU
2026-06-26 22:22:08 +00:00
Vitor PamplonaandClaude Opus 4.8 f5793937a7 Merge PR: fix(desktop): resolve Namecoin names in the home tab search bar
Merges nostr proposal ae364d99 into main: adds Namecoin (.bit) name
resolution to the desktop home-tab search bar (desktopApp FeedScreen.kt).
Network IO runs off the UI thread, stale lookups cancel via effect re-keying,
and it reuses the shared NamecoinNameResolver. (Nit deferred: extract a shared
rememberNamecoinResolution helper to de-dup with SearchScreen.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 18:19:23 -04:00
Vitor PamplonaandGitHub c67413544e Merge pull request #3392 from vitorpamplona/claude/ship-accrescent-6t68th
Add Accrescent APK set build & remove cleartext traffic flag
2026-06-26 18:02:53 -04:00
Vitor PamplonaandGitHub a56b5eff20 Merge pull request #3391 from vitorpamplona/docs/agent-ngit-pr-workflow
docs(claude): document the ngit + GitHub PR workflow for agents
2026-06-26 18:02:36 -04:00
Vitor PamplonaandClaude Opus 4.8 36ae65765d docs(claude): document the ngit + GitHub PR workflow for agents
Adds a `.claude/skills/ngit-pr` skill and a pointer from CLAUDE.md so
agents know how to create, review, revise, and merge PRs in this repo.

This repo can publish a PR two ways and the difference is easy to get
wrong: a GitHub remote (canonical `main`, normal `gh` flow) and a
git-over-nostr remote (`ngit`, where PRs are nostr proposals on
gitworkshop.dev and a push fans out to GitHub + the GRASP servers).

The skill identifies remotes by URL (names vary per clone; a collaborator
may have only one), explains which path to use, and documents the
three-mains alignment gate (GitHub vs the lagging nostr `main` vs local
`main`) that the nostr create/revise/merge flows all depend on — the
thing that otherwise causes rejected pushes and revisions that never
appear on gitworkshop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:55:34 -04:00
Vitor PamplonaandClaude Opus 4.8 f219153a7d Merge PR: fix(tor): wire Onion-Location interceptors into every OkHttp client
Merges nostr proposal e5865428 (v2) into main:
- fix(tor): wire Onion-Location interceptors into every OkHttp client
  (onionCache made non-nullable; OnionInterceptorWiringTest)
- refactor(napplet): route blob fetches through the shared OkHttpClientFactory
- fix(napplet): route brokered resource fetches by the applet's own Tor mode

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:41:28 -04:00
Vitor PamplonaandClaude Opus 4.8 1dbfd78b44 fix(napplet): route brokered resource fetches by the applet's own Tor mode
The consolidation passed `useProxy = true` for every brokered `resource.bytes`
fetch, forcing them through Tor whenever Tor was active — regardless of the
napplet/nSite's actual network mode. That overrides the user's explicit choice:
an nSite running in "open web" mode would still have its blob fetches tunneled,
inconsistent with how its own WebView page loads.

The authoritative per-applet preference already exists main-side in
NappletNetworkRegistry.useTor(coordinate) (locked napplets pinned to Tor;
nSites follow the persisted per-site toggle, which relaunches on change) — the
same source NappletLauncher reads to set the WebView proxy. Thread the calling
applet's coordinate through NappletResourceGateway.fetch so the broker can
resolve it, and pick the shared client with
getHttpClient(useProxy = NappletNetworkRegistry.useTor(coordinate)). This
mirrors the host's own `effectiveProxy = if (useTor) proxyPort else -1` exactly,
so a brokered fetch now routes like the applet's page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:32:57 -04:00
Vitor PamplonaandClaude Opus 4.8 c797033ba5 refactor(napplet): route blob fetches through the shared OkHttpClientFactory
The t4 onion-location proposal hand-wired OnionLocationInterceptor +
OnionUrlRewriteInterceptor into NappletResourceFetcher's private,
torPort-keyed OkHttpClient. That reached onion-routing parity but
duplicated the exact wiring OkHttpClientFactory already does, and the
private client still missed the local Blossom cache redirect, the shared
connection pool / HTTP-2 keepalive, and SurgeDns.

Inject the app-wide client instead: NappletResourceFetcher now takes a
() -> OkHttpClient and the broker supplies
`okHttpClients.getHttpClient(useProxy = true)` — the same DualHttpClientManager
path the image pipeline uses. Behavior-preserving for Tor (proxied when
Tor is active, clearnet when not) and, since these are sha256 blobs, the
shared Blossom-cache redirect is now a feature, not a loss. Drops the
private client + its cache and the hand-wired interceptors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 17:32:57 -04:00
mstrofnoneandVitor Pamplona 6209378b83 fix(tor): wire Onion-Location interceptors into every OkHttp client
Pins the Onion-Location interceptor wiring introduced in #3368 across
every OkHttp client the app builds and closes two gaps the audit
surfaced:

* OkHttpClientFactory.onionCache was nullable with a null default.
  A future call site constructing the factory without explicitly
  passing the cache would silently disable onion-routing for the
  entire HTTP role (image, upload, money, NIP-05, preview, push).
  Tightened to required (matches DualHttpClientManagerForRelays).

* NappletResourceFetcher built a raw OkHttpClient with no interceptors.
  Napplet HTTPS / blossom fetches over Tor would hit clearnet exit
  nodes even when the destination advertised an onion. Wired through
  the app-wide OnionLocationCache so a hint learned anywhere in the
  app applies, and vice versa.

ElectrumX is intentionally excluded: it uses raw Socket/SSLSocket,
not OkHttp, so the Onion-Location HTTP header does not apply. Tor
routing for ElectrumX continues to go through the Tor-aware
SocketFactory plus the Namecoin _tor field on the record (a
stronger, blockchain-anchored trust path than a passive HTTP hint).

IsEmulator is made null-safe (each Build.* field coalesced to "") so
unit tests can stand up the affected classes without NPEing on the
JVM default-values stub of android.os.Build.

New OnionInterceptorWiringTest (11 cases) covers:
  * locationInterceptor records the header under the clearnet host
    (HTTPS path and WebSocket 101 upgrade)
  * locationInterceptor with no header writes nothing
  * rewriteInterceptor passes through unknown hosts
  * rewriteInterceptor https -> https.onion preserves scheme
  * rewriteInterceptor https -> http.onion downgrades safely
  * rewriteInterceptor passes through unparseable cache values
  * cache round-trip and shared-instance invariant
  * compile-time pin that both classes remain okhttp3.Interceptor

Build:
  ./gradlew --no-daemon spotlessCheck                                       OK
  TZ=UTC ./gradlew --no-daemon :amethyst:testPlayDebugUnitTest              763 tests, 0 failures
  ./gradlew --no-daemon :commons:verifyKmpPurity :quartz:verifyKmpPurity    OK
2026-06-26 17:32:57 -04:00
Claude 284b7b143e ci: build signed Accrescent APK set (.apks) on release
Accrescent only accepts a signed APK set of split APKs generated by
bundletool from an AAB — not the AAB itself and not a monolithic APK.
After signing the F-Droid AAB, run bundletool build-apks (--mode=default)
to emit dist/amethyst-fdroid-<tag>.apks, signed with the same release
keystore secret the other signing steps use. It is attached to the GitHub
Release via the existing dist/* glob.

Upload to Accrescent stays manual (drag the .apks into the developer
console): Accrescent has no publish API or CI CLI yet — both are on their
roadmap but unreleased. A build-time guard warns if the APK set exceeds
Accrescent's 128 MiB automated-check limit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rE4k6sUN39emsofSv1Y1M
2026-06-26 21:27:16 +00:00
Vitor PamplonaandGitHub 2e69f85255 Merge pull request #3389 from vitorpamplona/claude/pr-rendering-layout-uafuio
Style Git pull request clone URLs with labelMedium typography
2026-06-26 17:19:45 -04:00
Vitor PamplonaandGitHub 1788c93358 Merge pull request #3388 from vitorpamplona/claude/browser-nsite-preload-ekriqv
Preload favorited nSites/nApplets to avoid "isn't loaded yet" toast
2026-06-26 17:05:21 -04:00
Claude e70688841c fix: preload favorited nsite/napplet manifests in the browser
Favorites store only the addressable coordinate (kind:pubkey:dtag); the
launch path re-resolves the live event from LocalCache at tap time. When
that event hadn't streamed in yet, tapping a favorited nsite/napplet showed
"isn't loaded yet" and never recovered on its own — the only thing that
pulled the event into the cache was visiting the nsite/napplet feed (it
subscribes by author), which is why opening that feed and coming back made
the favorite suddenly launchable.

Add PreloadFavoriteNostrApps, which subscribes each favorited coordinate to
the shared EventFinder (the same lifecycle-aware loader observeNote uses) so
the manifests fetch via the author's outbox relays as soon as the launcher
opens. Wire it into the Browser tab and the Favorite Apps tab. The loader
drops each coordinate once its event arrives, so this is a one-shot fetch,
not a standing feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XH7D6xUhHDKUbrYHoBnZyL
2026-06-26 20:53:46 +00:00
Claude 9002c63916 feat: reorder PR card with repo header on top and size clone links
Move the project's name (repository) row to the top of the pull-request
card, above the type/status row. Render the clickable clone download
links at the same font size as the branch/commit/merge-base meta rows so
the metadata block reads as one consistent sequence. Apply the same link
sizing to the PR update card for consistency.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FjxBXAx2NQE6xi3TLBNJ23
2026-06-26 20:44:12 +00:00
Claude 11a2474338 fix: drop redundant usesCleartextTraffic flag for Accrescent compliance
Accrescent's automated checks reject any app whose manifest sets
android:usesCleartextTraffic="true". The attribute is already inert on
this app: it is ignored on API 24+ whenever a networkSecurityConfig is
present, and minSdk is 26, so cleartext is governed entirely by
network_security_config.xml (base-config cleartextTrafficPermitted=true).

Removing the attribute is behavior-preserving — user-configured ws://
relays on local IPs and the 127.0.0.1 Tor SOCKS proxy keep working via
the network security config — while satisfying Accrescent's check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014rE4k6sUN39emsofSv1Y1M
2026-06-26 20:33:52 +00:00
Claude 69457ec10d build: make release APKs reproducible
AGP embeds a dependency-metadata blob (the resolved dependency tree, a
protobuf encrypted with a Google public key) into the APK/AAB signing
block by default. That ciphertext is non-deterministic, so it was the one
remaining thing preventing our release artifacts from being rebuilt
bit-for-bit by a third party.

Disable it via dependenciesInfo { includeInApk = false; includeInBundle =
false }. Combined with the already-pinned toolchain (AGP/Kotlin/R8/JDK 21),
the absence of any build-time clock in BuildConfig, and a deterministic
version name, release APKs now reproduce exactly — letting F-Droid /
Zapstore independently verify our developer-signed builds. Play derives
this dependency data server-side, so nothing is lost there.

Document the guarantee and a diffoscope-based verification recipe in
BUILDING.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JtjUcSjjpu4auFndw1QKeU
2026-06-26 20:28:38 +00:00
Vitor PamplonaandGitHub 52a467db5f Merge pull request #3387 from vitorpamplona/claude/zap-card-bg-color-kz8szr
Fix activity card background propagation to embedded notes
2026-06-26 16:26:02 -04:00
Claude d6fc3232b2 fix: keep zap/reaction card inner content on the card's tint
The embedded note and comment inside the lightning / nutzap / onchain-zap
and reaction activity cards were handed the parent feed / MultiSetCard
background state, so they drew black (the app background) or flashed the
new-note highlight instead of letting the card's orange (or like-tinted)
wash show through.

ActivityCardFrame now exposes a stable transparent background state to its
content for the inner note and comment to draw on; the hand-built onchain
card uses a matching local state. Nothing inside the card's layout changes
background from the feed anymore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GEUsgVr5e31qYeqNSjgHif
2026-06-26 19:37:43 +00:00
Vitor PamplonaandGitHub 19debffe15 Merge pull request #3384 from vitorpamplona/claude/nsite-napplet-icons-okz5y6
Show nSite/nApplet icons from bundled blobs in favorites
2026-06-26 15:16:43 -04:00
Claude c7e14676f2 Merge remote-tracking branch 'origin/main' into claude/nsite-napplet-icons-okz5y6 2026-06-26 19:11:40 +00:00
Claude 2fddb73365 Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-26 19:08:04 +00:00
Claude e8539df81e Merge remote-tracking branch 'origin/main' into claude/nsite-napplet-icons-okz5y6
# Conflicts:
#	amethyst/src/main/res/values-fr-rCA/strings.xml
2026-06-26 19:07:03 +00:00
Vitor PamplonaandGitHub b703330c6a Merge pull request #3386 from vitorpamplona/claude/amethyst-lint-translations-dend0l
Move browser strings to commons for shared localization
2026-06-26 15:06:54 -04:00
Vitor PamplonaandGitHub 7fc5c92760 Merge pull request #3385 from vitorpamplona/fix/account-dir-cleanup-on-delete
fix: delete per-account dir on account removal + sweep orphans at startup
2026-06-26 15:05:42 -04:00
Claude 0092b4f726 perf: single-pass icon-path selection; resolve on event not NoteState
NappletIconPath.choose scanned the whole path list once per conventional name
(O(PRIORITY x N)), allocating a filter list and recomputing each basename up to
20 times per path. A manifest is a whole static site, so N can be large. Collapse
to one O(N) pass with a name->rank map: basename computed once per path, no
intermediate lists, and the loose-raster fallback is skipped entirely once an
exact match is in hand. Behavior is unchanged (the 11 selection tests still pass).

Also key the favorite-icon resolution on the manifest event rather than the whole
NoteState, so paths()/choose() don't re-run on unrelated metadata bumps (a
reaction or zap tracked on the note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 19:00:53 +00:00
Claude 5a297f17a1 fix: move orphaned browser/napplet translations to :commons
The browser/napplet strings (browser_address_hint, browser_console_title,
browser_console_title_short, browser_console_clear, napplet_untitled) were
moved to :commons, but their per-locale translations were left behind in
amethyst's values-*/strings.xml. With the default keys gone from amethyst,
lint flagged them as ExtraTranslation (80 errors across 16 locales).

Move the translations into commons/src/androidMain/res/values-*/strings.xml
so the default key and its translations live in the same module, preserving
the existing translation work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uza7sGxYPZtY43Ln2yH8FQ
2026-06-26 18:58:08 +00:00
Claude 2fac0dae1d Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280 2026-06-26 18:56:50 +00:00
Vitor PamplonaandGitHub 0afc83dcfb Merge pull request #3383 from vitorpamplona/claude/amethyst-draft-signing-bug-9cx0zx
Fix draft deletion by holding strong reference to draft notes
2026-06-26 14:52:24 -04:00
Vitor PamplonaandClaude Opus 4.8 58b551265c fix: delete per-account dir on account removal + sweep orphans at startup
Account deletion cleaned up the saved-accounts list and encrypted prefs but
never removed the on-disk files/accounts/<pubkey>/ directory (the MLS/Marmot
stores created in AccountCacheState.loadAccount). Every deleted or logged-out
account leaked its folder, so the on-disk account count drifted far above the
number of accounts shown in the switcher (16 dirs vs 6 saved on a test device).

- AccountCacheState: add deleteAccountFiles(pubkey) to remove the directory and
  pruneOrphanAccountDirs(keepPubkeys) to clear dirs no longer backed by a saved
  account.
- AccountSessionManager.logOff: delete the files in both delete branches.
- AppModules: one-time startup sweep, keyed by the hex of every saved account,
  to clean up folders leaked before this fix.
- LocalPreferences.savedAccounts(): make the lazy init race-safe with a
  dedicated mutex + double-checked locking. Multiple startup coroutines
  (account load, always-on notification service, the new orphan sweep) call it
  concurrently; the old check-then-act could run the IO read in parallel and
  double-write ALL_ACCOUNT_INFO during the legacy migration.

Verified on-device: 16 -> 6 account dirs after one launch, stable across
restarts, no startup regressions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 14:47:23 -04:00
Claude 119517bd3a fix: only resolve the draft note when there is a version to save
The versions collector resolved draftNote = account.getOrCreateDraftNote(...)
on every emission, including the content-less initial tick (~1s after the
composer opens). That touched the lateinit account before any user input; if
it were ever unset at that moment the throw would kill the collectLatest
coroutine and silently stop all draft saves for that composer.

Move the resolve inside the `if (it > 0)` guard, co-located with the save, so
account is only read once a real edit exists. The open-a-draft-then-send-
without-editing case is still covered by the explicit refresh in load().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 18:41:23 +00:00
Claude 0186aad1f4 fix(cashu): audit fixes for wallet wizard
Three issues found in an adversarial audit of the wizard:

1. (correctness) recoverFromSeed bumped the NUT-13 counter only when
   unspent proofs remained, but nextCounterAfterScan reflects every slot
   the mint signed regardless of spent state. Adopting a fully-spent
   wallet on a fresh device would leave the counter at 0, so the next
   mint would reuse an already-signed slot and produce an unspendable
   proof. Now bumps past every signed slot (matches the old
   restoreFromMint behavior); the delta>0 check still no-ops when nothing
   was signed.

2. (UX trap) the wallet screen's auto-launch-into-wizard guard used
   `remember`, which resets when the screen leaves composition on
   forward navigation — so backing out of the wizard re-fired the effect
   and trapped the user in an inescapable loop. Switched to
   rememberSaveable so the guard survives the round trip.

3. (robustness) wrapped the wizard's analyze() in try/catch so an
   unexpected throw surfaces an error + retry instead of spinning on
   "Analyzing…" forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 18:31:37 +00:00
Claude bb968f74f2 fix: make nsite/napplet favorite icon reactive to manifest arrival
The first cut resolved the manifest once inside remember(coordinate), so if the
event wasn't in LocalCache at first composition (e.g. a cold start, where it
streams in from relays a moment later) the null result was cached for the life
of the composition and the icon never appeared — a silent fall-back to the glyph.

Observe the addressable note's metadata StateFlow instead, mirroring the webapp
favicon path (which re-resolves on the BrowserIconRegistry key set): when the
manifest lands or updates in LocalCache, the icon resolves and the blob fetch
kicks off. checkGetOrCreateAddressableNote returns null only for a malformed
coordinate, so the early return stays structurally stable per call site.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 18:23:52 +00:00
Claude 172df295f8 refactor(cashu): rename AddCashuWalletScreen to CashuMintsScreen
The screen is no longer an "add wallet" entry point — it's a settings-only
mint manager (first-time setup now goes through the find-or-create wizard).
Rename the composable AddCashuWalletScreen → CashuMintsScreen and the route
WalletAddCashu → CashuWalletMints to match its actual role.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 18:21:22 +00:00
Claude 94b99e0973 refactor: derive the draft note from the versions flow, decouple DraftTagState
DraftTagState goes back to pure tag/version state — it no longer knows about
AddressableNote or needs an account-aware builder. Instead each composer
derives draftNote from the debounced versions collector it already runs:
on each emission it maps the current tag to its live cache note via
account.getOrCreateDraftNote(current). The ViewModel field holds the strong
reference that keeps LocalCache's weak entry alive until a deletion needs it.

load() refreshes draftNote after set(oldTag) because set() doesn't bump
versions, covering the open-a-draft-then-send-without-editing case.

deleteDraftInner takes the nullable note again and still only signs when the
note holds a real, non-deleted DraftWrapEvent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 18:16:42 +00:00
Vitor PamplonaandGitHub 799bbe68ef Merge pull request #3382 from vitorpamplona/claude/amethyst-logo-redesign-nlefdg
Redesign service notification icon with network topology
2026-06-26 14:11:55 -04:00
Claude 5b8a42020c feat: adopt orbit-ring service notification icon; drop preview scaffolding
Make the orbit design (centred gem ringed by three "server" nodes) the
always-on relay-service notification icon by writing it into the existing
amethyst_service drawable, so NotificationRelayService picks it up unchanged.

Remove the icon bake-off scaffolding now that a design is chosen:
- delete the alternative drawables (amethyst_service2..6)
- delete the DEBUG-only ServiceIconPreviewNotifications helper
- drop its trigger and now-unused imports from MainActivity

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 18:04:42 +00:00
Claude 6b6ee26a88 style: maximize centred gem in the service-icon options
Grow the central Amethyst gem in each motif (orbit/sync/hub/waves) to the
largest size that still clears the surrounding elements. For the hub icon the
spokes now start farther from the centre to give the gem room.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 17:57:31 +00:00
Claude 111686884a fix(i18n): drop CDATA from French "Block & Hide" strings
block_hide_user and report_dialog_block_hide_user_btn wrap their value in
<![CDATA[...]]> in the English source only because the text contains a literal
'&' (Block & Hide User). The French translations kept the CDATA wrapper but the
text has an apostrophe (l'utilisateur) instead — and AAPT2 fails to flatten a
CDATA-wrapped apostrophe ("Can not extract resource from ParsedResource" /
"Invalid unicode escape sequence"), breaking mergePlayDebugResources.

Drop the now-pointless CDATA and use a normally-escaped apostrophe (l\'utilisateur)
— identical runtime string, valid under every AAPT2 version, and consistent with
the escaped-apostrophe style the rest of these files already use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 17:45:21 +00:00
Claude cde3b2b047 feat(cashu): add find-or-create wallet wizard with cross-relay discovery
The Cashu "Add wallet" screen had become a mint manager, and its Create
path published a fresh kind:17375 that could clobber a portable NIP-60
wallet the user already owned in another client (kind:17375 is
replaceable).

When no wallet is loaded, drive the user into a new find-or-create wizard
that crawls every relay (modeled on the Event Sync tool) for the user's
existing kind:17375 wallets and branches on the result:

- 0 wallets: offer to create a new one.
- 1 wallet: verify + balance-probe it, adopt as the main wallet and
  rebroadcast to outbox so it's easy to find next time.
- >1 wallets: the newest becomes the main wallet; older/duplicate wallets
  are verified, their recoverable balances probed via NUT-09/NUT-07, and
  the user gets one-tap "Recover funds to main wallet" per old wallet.

The mint manager (AddCashuWalletScreen) is now reachable only from Cashu
Wallet Settings, once a wallet exists; the no-wallet and "add wallet"
picker paths route through the wizard instead.

Implementation:
- Split CashuWalletOps.restoreFromMint into scanRecoverableProofs (no
  publish, for the balance probe) + publishRecoveredProofs; foreign-seed
  recovery never bumps the main wallet's NUT-13 counter.
- New CashuWalletDiscovery crawler reuses fetchAllPages + a fresh
  NostrClient so crawled events don't pollute LocalCache.
- CashuWalletState gains decrypt/probe/recover/adopt helpers.
- Extract AccountViewModel's relay-crawl closures (crawlRelayDb,
  buildCrawlClient) so Event Sync and the wizard share them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EqmMR2QiULS5QGosSgSQAe
2026-06-26 16:22:18 +00:00
Claude eb6dfc7a4a feat: show nsite/napplet bundled icon on favorite tabs
Favorited web apps replace the bottom-nav glyph with the site's favicon, but
nsites (NIP-5A) and napplets (NIP-5D) fell back to the generic grid glyph.

The webapp trick (capturing onReceivedIcon from the WebView) can't work here:
nsites/napplets render inside a cross-origin sandboxed iframe under a trusted
shell, so onReceivedIcon only ever reports the shell's main-frame icon, never
the applet's. Instead, derive the icon from the manifest's own bundled blobs —
content-addressed, sha256-verified, and Tor-routed like the rest of the site.

- quartz: NappletIconPath picks the best conventional icon path (favicon/icon/
  apple-touch-icon, raster over ico/svg, shallower path wins) from a manifest's
  path tags; exposed as iconBlob() on the four nsite/napplet event kinds. Unit
  tested.
- amethyst: rememberNappletIconModel resolves the live manifest from LocalCache,
  prefetches the icon blob into the shared verified cache off the composition
  thread, and returns a file:// model. AppBottomBar and FavoriteAppsScreen feed
  it into FavoriteAppIcon for NostrApp favorites, mirroring the webapp path.

Priority: bundled blob > manifest icon URL tag > type glyph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaPdt8EnSrSBuzdudrxzTH
2026-06-26 15:55:12 +00:00
Claude 002c8b6d30 style: enlarge service-icon options to fill the icon frame
Scale up the orbit/sync/hub/waves motifs and their centred gems so each
drawing occupies as much of the 512 viewport as possible (rings and nodes
pushed near the edge, larger central gem), keeping a small margin so strokes
don't clip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 15:53:11 +00:00
Claude b5706564d9 refactor: build the draft note from the tag inside DraftTagState
Instead of capturing the AddressableNote returned by each save (and on
load) and re-assigning it, DraftTagState now builds the note from the
current tag via an injected (tag -> AddressableNote) builder and rebuilds
it whenever the tag changes. Because that note is the live cached object
for the address, its event tracks the draft automatically as it is saved
or removed, so:

- the note is never null inside the state (lateinit, wired by start());
- createAndSendDraftIgnoreErrors no longer needs to return the note, and
  load no longer needs to capture it — set(oldTag) rebuilds it;
- the writer's existence check becomes "is there a real, non-deleted
  draft event in the note" (DraftWrapEvent.isDeleted()), which also stops
  a second blank-delete from re-signing an already-emptied draft.

ViewModels just wire draftTag.start(account::getOrCreateDraftNote) in
init() and reference draftTag.note; the per-VM field and held() are gone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 15:38:55 +00:00
Claude e1c152ada8 feat: add four more service-icon options + on-device preview harness
Add four additional always-on service notification icon candidates, each
keeping the centred brand gem with a "background service / connected to
servers" motif around it:

- amethyst_service3: orbit ring with three server nodes
- amethyst_service4: two looping sync arrows (running)
- amethyst_service5: hub & spoke to five nodes (connected to relays)
- amethyst_service6: concentric broadcast waves

To compare all candidates on a real device, add a DEBUG-only helper
(ServiceIconPreviewNotifications) that posts one always-on-style ongoing
notification per icon (same channel style, ongoing/silent/low priority),
triggered from MainActivity.onCreate under BuildConfig.DEBUG.

NOTE: the preview harness (ServiceIconPreviewNotifications + the
MainActivity hook) is temporary scaffolding for the icon bake-off and is
meant to be removed once a design is chosen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 15:33:15 +00:00
Vitor PamplonaandGitHub 6b7e541f55 Merge pull request #3380 from vitorpamplona/claude/adoring-babbage-stzzck
Auto-save Cashu wallet on mint add/remove, remove manual key picker
2026-06-26 11:16:48 -04:00
Claude a242411df6 refactor: own the draft note reference inside DraftTagState
The strong reference that keeps a saved draft alive (so LocalCache's weak
reference can't collect it before deletion) was duplicated as a draftNote
field across all eight composer ViewModels, each re-clearing it in
cancel(). The note's lifecycle is 1:1 with the draft tag, so DraftTagState
is its natural owner: it now holds the AddressableNote, exposes held() to
set it, and drops it in rotate() — which every cancel() already calls.

ViewModels now reference draftTag.note / draftTag.held(...) and no longer
carry their own field or reset logic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 15:15:42 +00:00
Claude b4d39a7e52 feat: auto-publish Cashu wallet on mint add/remove, drop Save button
The Add/Edit Cashu wallet screen no longer has a Save button. The NIP-60
wallet (kind:17375) and nutzap info (kind:10019) are now published the
instant the first mint is added, and re-published on every later add or
remove — so the list on screen always matches what's on relays.

This removes the confusing two-step flow where a typed/selected mint plus a
chosen key still left Save disabled until the user discovered the "+" button.

The explicit P2PK key picker (auto-generate / paste) is gone from this
screen: a nutzap key is generated automatically on first creation. Advanced
key import/rotation still lives in the settings Danger Zone.

Key safety: publishMints reuses the wallet's existing P2PK key on every
re-publish. The first publish's key is cached in the ViewModel and guarded
by a mutex, so rapid successive adds — firing before the new kind:17375
round-trips back through LocalCache — can't generate a second key and
rotate it, which would orphan inbound nutzaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk
2026-06-26 15:09:20 +00:00
Vitor PamplonaandGitHub 356e1431d8 Merge pull request #3378 from vitorpamplona/fix/embed-ime-selection
Embedded text selection: native-parity IME + selection UI + magnifier, and platform-bug fixes
2026-06-26 11:08:42 -04:00
Vitor PamplonaandGitHub 65f5292f6b Merge pull request #3379 from vitorpamplona/claude/notecompose-zap-split-preview-dgn0js
Show boosted notes as compact preview in activity cards
2026-06-26 11:08:17 -04:00
Claude 50dbbeeecc fix: hold a strong reference to the draft note for reliable deletion
LocalCache.addressables keeps AddressableNotes via WeakReference, so a
saved draft could be garbage-collected between creation and deletion.
The previous existence check (look the draft up by tag before signing)
would then find nothing locally and skip the deletion, leaving an orphan
draft on the relays.

Each composer ViewModel now holds a strong reference to its draft note:
createAndSendDraftIgnoreErrors returns the consumed AddressableNote, and
load()/editFromDraft captures the note when editing an existing draft.
deleteDraftInner takes that held note directly (sourcing the dTag and
relays from it) instead of a tag lookup, so it can always reach the draft
it needs to delete and still signs nothing when there is no draft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 14:59:44 +00:00
Vitor PamplonaandClaude Opus 4.8 11e1e17655 docs(embed): record session fixes in the native-parity plan
Mark the full-screen round-trip corruption bug FIXED (process-global pauseTimers
+ attached WebView.destroy), and add the no-blink word-select, page-selection-
clears-on-focus, caret-handle-drag (positionChangeIgnoreConsumed), hybrid
word+char extend, and embed app-theme (nightThemedContext) fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 10:57:36 -04:00
Claude bacb8a57ad feat: add circular-badge service icon as amethyst_service2 option
Keep the original hollow-outline amethyst_service icon and add the new
circular badge (solid disc with the gem punched out of its centre) as a
separate amethyst_service2 drawable, so the always-on notification icon
can be switched between the two without losing either design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 14:57:22 +00:00
Claude bf2dc03a50 fix: escape apostrophe inside CDATA in fr-rCA strings
The Crowdin-synced values-fr-rCA/strings.xml had unescaped apostrophes inside the
CDATA sections of block_hide_user and report_dialog_block_hide_user_btn, which
aapt2 rejects ("Invalid unicode escape sequence"), breaking resource compilation.
Escape them as \' to match the known-good values-fr translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4rrFiWApq8EFpk4fSuTFH
2026-06-26 14:54:09 +00:00
Vitor PamplonaandGitHub f458fdaa8a Merge pull request #3377 from vitorpamplona/claude/amethyst-git-notifications-rqfkux
Add Git pull request event rendering support (NIP-34)
2026-06-26 10:53:30 -04:00
Claude 120c31ac95 feat: always show short preview for posts inside zap activity cards
The lightning, nutzap, and onchain activity cards embed the zapped post via
RenderZappedPost with makeItShort = true. The 2-line compact preview, however,
only triggered when the logged-in user authored the post
(makeItShort && isLoggedUser(author)). When the user is merely a zap-split
beneficiary of someone else's post, that check failed and the post rendered in
full instead of the intended compact preview.

Gate the short preview on the boosted-note flag as well, so any post embedded in
one of these activity cards (the only makeItShort callers that pass
isBoostedNote = true) is always shown as a 2-line preview, regardless of author.
Other makeItShort callers (Report, community post approval, attestation, reply
composition, compose screens) all use isQuotedNote instead, so their behavior is
unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4rrFiWApq8EFpk4fSuTFH
2026-06-26 14:49:47 +00:00
Vitor PamplonaandClaude Opus 4.8 43aebcca63 fix(embed): make embedded + full-screen WebViews follow the app theme
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>
2026-06-26 10:49:44 -04:00
Claude 3c041d2915 feat: modern git cards for pull requests, plus issue/patch polish
NIP-34 pull requests (kind 1618) and pull-request updates (kind 1619)
previously had no renderer and fell through to the plain text-note
branch, so a PR notification opened onto an unstyled markdown blob with
none of its structured data shown. Add dedicated cards that reuse the
existing patch/issue card vocabulary (bordered container, type chip,
status pill, embedded repository header) and surface the PR-specific
metadata the event carries:

- Pull Request card: "Pull Request" chip with a merge glyph, status
  pill, subject title, branch name, current commit, merge base, and
  clone-URL download rows.
- PR Update card: "PR Update" chip, repository header, new commit /
  merge base, clone URLs, and an explanatory line (updates carry no
  body content).

While here, modernize the existing cards consistently:

- Factor the shared markdown body, metadata row, and subject title into
  reusable composables (GitMarkdownBody / GitMetaRow / GitSubjectTitle).
- Show the issue subject as a proper title. The old code cast the event
  to TextNoteEvent to read the subject, which always returned null
  (GitIssueEvent is not a TextNoteEvent), so issue subjects were never
  displayed; read it from GitIssueEvent.subject() instead.
- Render the patch commit as an iconed metadata row.

Wire the two new kinds into NoteCompose and the thread detail view, add
the CallMerge / Commit / AltRoute Material symbols (font subset
regenerated), and add the new string resources.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:39:05 +00:00
Claude df5361d220 fix: don't sign a draft deletion when no draft exists
deleteDraftInner() unconditionally signed a deleted-draft wrap event plus
a deletion event, even when no draft for that tag existed. With an
external NIP-55/NIP-46 signer, clearing a composer to blank could then
prompt for a signature even when "Automatically create drafts" is off,
since the blank-text branch of sendDraftSync() always calls delete.

Skip both signatures when the draft does not exist in the cache. The
existence lookup reuses the addressable note already fetched for relay
hints, so it adds no extra work, and real drafts are still deleted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 13:38:20 +00:00
Claude 7234324332 fix: escape apostrophes in fr-rCA CDATA strings
Two French-Canadian CDATA string resources carried a raw apostrophe
(`l'utilisateur`), which aapt2 rejects with "Invalid unicode escape
sequence", breaking every Android resource merge (merge*Resources) and
therefore the whole Android build. Escape them as `\'` like the other
apostrophes in the file.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:09:47 +00:00
Claude 173a422d86 fix: notify on NIP-34 pull requests (kind 1618/1619)
New pull request events (GitPullRequestEvent, kind 1618) and pull
request updates (GitPullRequestUpdateEvent, kind 1619) were never wired
into the notification pipeline, which only handled patches (1617) and
issues (1621). As a result repo maintainers received no notification
when a PR was opened against their repository, even though the PR event
p-tags the repo owner.

Add both kinds in all four places that gate notifications:

- FilterNotificationsToPubkey: request the kinds from relays so the
  notification subscription actually pulls PR events.
- NotificationFeedFilter.NOTIFICATION_KINDS: let cached PR events through
  to the in-app Notifications tab.
- NotificationFeedFilter.tagsAnEventByUser: treat PR/PR-update as
  notifiable (mirrors the existing patch/issue handling).
- EventNotificationConsumer: dispatch PR/PR-update via notifyMention so
  push notifications fire.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018wCXL6btUeZZmSP1TCYkKh
2026-06-26 13:00:39 +00:00
Claude fa12576a8c fix: respect "Automatically create drafts" setting in comment composer
CommentPostViewModel.sendDraftSync() unconditionally signed and
published a draft event whenever the user typed in a comment, ignoring
the "Automatically create drafts" setting. Every other composer
ViewModel guards this path with
accountViewModel.settings.automaticallyCreateDrafts(); add the same
check here so disabling the setting stops generic draft events from
being signed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016aj4ajZ4uQ58Bqts7riJ5i
2026-06-26 12:29:37 +00:00
Vitor PamplonaandGitHub 69e9590fe7 Merge pull request #3374 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-26 08:25:50 -04:00
Claude 27191c3a36 fix: enlarge gem in service notification icon by 10%
The punched-out gem now spans ~57% of the disc diameter (was ~52%),
giving it slightly more presence within the circular badge.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 02:06:49 +00:00
vitorpamplonaandgithub-actions[bot] f5bb04b643 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-26 01:34:37 +00:00
Vitor PamplonaandGitHub f2cc46641c Merge pull request #3376 from vitorpamplona/claude/keen-franklin-h4kah5
feat: query zapstore's relay in the global Apps feed and keep its filter specific
2026-06-25 21:32:20 -04:00
Vitor PamplonaandClaude Opus 4.8 6995082334 fix(embed): full-screen round-trip no longer corrupts embedded WebViews
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>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 aa520c361f feat(embed): hybrid word+char granularity for in-field selection handles
Completes parity #5. `fieldExtend` kept a pure word-snap, so the in-field
start/end handles couldn't be fine-tuned to a single character. Now it keeps
per-drag state (reset on a >250ms gap or edge switch) and matches native
`Editor` word-selection drags: the gesture baselines at the current selection
edge, sweeping past that word's far boundary snaps to the next whole word
(never stopping mid-gap), and moving within / back from the furthest-reached
word gives character precision. Symmetric for both handles. Page-text extend
stays character-granular.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 dec1e0bec1 feat(embed): selection loupe + native-parity fixes for embedded text selection
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>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 8232cd50a3 test(embed): add IME/selection test harness + document how to run it
tools/ime-test/index.html is a single-page input+textarea harness with an
on-page log timestamping focus/selection/input/composition events, paint
latency, long-tasks, and main-thread blocks — the instrumentation used to pin
the erase / caret-jump / first-letter-freeze bugs and what we'll use to profile
the magnifier. README documents serving it (python http.server on 8765,
10.0.2.2 for emulator / adb reverse for USB) and opening it as an embedded tab
via the in-app browser address bar. Plan gets a matching "How to test" section.

Dev tool only — nothing under tools/ ships, so the [ImeDiag] strings stay out of src/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 d351376e51 docs(embed): plan for native-parity text selection in embedded surfaces
Inventory native Android text-field features (insertion/selection handles,
floating toolbar, magnifier, smart selection, etc.) with activation rules and
our current coverage; prioritize the magnifier and a centralized SelectionUiState.
Document the open full-screen round-trip bug that kills highlight paint across
all :napplet embeds, what was already ruled out, and the next hypotheses.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandClaude Opus 4.8 baddd5b3bd fix(embed): IME typing + host-drawn text selection for embedded surfaces
Embedded WebView surfaces (:napplet process, SurfaceControlViewHost) can't
host the soft keyboard or present Chrome's own selection UI, so editing and
selection are relayed to the main process. This lands the working set of that
relay:

- shim.js: fix React-controlled input erase by writing through the native
  HTMLInputElement/HTMLTextAreaElement value setter (so React's value tracker
  stays in sync); make the host authoritative for selection re-assert (the
  editable selectionchange handler only mirrors); report field/caret geometry
  and page-text selection geometry; add pageExtend + caret coords (border-width
  corrected) for drag-to-extend and the insertion handle.
- RemoteImeView: land caret where tapped on focus (requestFocus before applying
  remote state); host-authoritative selection re-assert within a time window;
  setText only when text actually changed; wire copy/cut/paste/select-all and
  edit callbacks.
- EmbeddedTabLayer: stop resizing the surface on IME show (removes the ~1s
  first-letter freeze); draw the selection overlay — toolbar, teardrop
  selection handles, and the insertion (cursor) handle.
- EmbeddedImeBridge / Embedded{Napplet,Browser}Controller: carry selection +
  caret geometry and the page-selection event across the Messenger channel.

Known limitation (not fixed here): after a field's page is opened in its own
full-screen activity and the user returns, the selection-highlight paint stays
off across all embedded surfaces. DOM selection, the toolbar, and copy still
work — only the native highlight is gone. This is a WebView/Chromium behavior
in off-window surfaces and is not reachable from the app layer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 21:25:55 -04:00
Vitor PamplonaandGitHub 91df3c53ae Merge pull request #3375 from vitorpamplona/claude/compiler-instruction-limit-0ky2hw
Fix compiler instruction limit in AccountSettings constructor
2026-06-25 21:25:21 -04:00
Claude 8b2b43d3c4 feat: redesign always-on service notification icon as a circular badge
The persistent relay-service notification previously reused a hollow gem
outline, which looked unpolished. Replace it with a full, solid disc that
has the brand gem punched out of its centre as negative space (slightly
smaller, with a comfortable surrounding ring).

The enclosing circle gives the always-running service its own distinct,
continuous "badge" feel while keeping the recognisable Amethyst gem shape.
Built from the real gem path so it stays on-brand; a single evenOdd path
turns the gem into a hole and restores its facet dot as a solid island.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kvjst6qVHuQ8rKD3LtvTzi
2026-06-26 01:19:16 +00:00
Claude 890516263f feat: query zapstore's relay in the global Apps feed and keep its filter specific
The global Apps feed (NIP-82 software apps) was sending a kinds-only REQ
filter with limit=200. zapstore's relay scores each REQ filter for
specificity and rejects anything that scores below 3, treating a kinds-only
filter with a limit >= 100 as "too vague" — so the whole subscription was
refused.

- Drop the global Apps filter limit from 200 to 99 so a kinds-only filter
  clears the bar (kinds +1, limit < 100 +2 = 3).
- Always include wss://relay.zapstore.dev in the global Apps feed relays,
  since it indexes the full app catalog, unless the user has added it to
  their blocked relay list (NIP-51 kind 10006).
- Re-evaluate the subscription when the blocked relay list changes so
  blocking/unblocking zapstore takes effect without an app restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01US2aK3igzfeo4xtcvg3mKc
2026-06-26 01:17:05 +00:00
mstrofnone d4cc4c67de fix(desktop): resolve Namecoin names in the home tab search bar
The home tab inline search bar in FeedScreen.kt previously ignored
Namecoin identifiers (e.g. mstrofnone.bit) — only the full Search screen
resolved them via LocalNamecoinService. Users typing a .bit name into
the home search pill saw 'no results found' instead of the resolved
profile.

This wires the same Namecoin resolution pattern used by SearchScreen.kt
into FeedTabsHeader:

- Detect Namecoin identifiers with NamecoinNameResolver.isNamecoinIdentifier
- Resolve via LocalNamecoinService.resolveDetailed (cancelling stale lookups
  when the user keeps typing)
- Render a compact InlineNamecoinResultRow above the regular search results
  showing Loading / Resolved / NotFound / Error states
- Clicking the resolved row navigates to the user profile, matching the
  full Search screen behaviour
2026-06-26 10:58:44 +10:00
Claude 0b06754297 fix: avoid compiler instruction limit in account loading
innerLoadCurrentAccountFromEncryptedStorage awaited ~27 parallel parse
Deferreds directly inside the single 70-argument AccountSettings(...)
constructor expression. Each await is a suspension point, so the coroutine
state machine had to spill and restore the entire partially-evaluated
operand stack (dozens of already-built MutableStateFlow args) at every one
of them. That inflated the generated invokeSuspend method past the JVM
per-method limit:

  Method exceeds compiler instruction limit: 57866 in
  LocalPreferences$innerLoadCurrentAccountFromEncryptedStorage$result$1.invokeSuspend

Resolve every Deferred into a local val first, then build AccountSettings
as a single straight-line, suspension-free expression. Each await now sits
at a statement boundary with a near-empty operand stack, so only a small,
linear set of locals is spilled. Parallel parsing behaviour is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H6VdHHFRNYXKXFcW7j5sF7
2026-06-26 00:45:46 +00:00
Vitor PamplonaandGitHub f79877adc6 Merge pull request #3371 from vitorpamplona/claude/laughing-volta-umdxsu
Add and update translations across 50+ locales
2026-06-25 20:25:21 -04:00
Claude 908e8bc1ee fix: remove invalid backslash escapes from all locale string files
Android's AAPT2 resource processor only recognizes a small set of valid
escape sequences: \, \', ", \n, \t, \r, \@, \?. All other \X sequences
(backslash-space, \-, \., \(, \), $, etc.) render as a literal backslash
followed by the character in the UI.

These were introduced by the batch AI translation. Remove the backslash
before every character that does not need escaping across 38 locale files.
2026-06-26 00:21:17 +00:00
Claude e5a83a4123 fix: remove duplicate translation entries in hu-rHU and sl-rSI
The appended translation blocks from the previous batch-translation session
overlapped with existing Crowdin translations already present in the files,
causing 31 duplicate keys in values-hu-rHU and 3 in values-sl-rSI.
Keep the first (original Crowdin) occurrence of each key.
2026-06-26 00:13:39 +00:00
Claude f2a7548434 fix: move shared browser/napplet strings to :commons to eliminate cross-module duplicate
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)
2026-06-26 00:11:31 +00:00
Claude a1732155b1 fix: restore browser/napplet strings removed from values/strings.xml
The previous commit incorrectly removed browser_address_hint,
browser_console_clear, browser_console_title, browser_console_title_short,
and napplet_untitled from values/strings.xml. These are needed for
amethyst's own browser and napplet screens — both amethyst and nappletHost
use them independently, and Android allows app-level strings to coexist
with same-named library strings (app wins).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:54 +00:00
Claude f8a9920884 fix(i18n): resolve aapt2 duplicate resource errors in hu and sl-rSI
Remove 2959 strings from values-hu/strings.xml that were fully duplicated
in values-hu-rHU/strings.xml (added by prior commit), causing aapt2 to
error on `browser` and all other keys for the hu-HU build target.

Remove browser_console_clear, browser_address_hint, browser_console_title,
browser_console_title_short, and napplet_untitled from the app's default
values/strings.xml — these are already defined in the :nappletHost library
module, and having them in both causes aapt2 duplicate-key errors when the
library resources are merged into the app. Locale translations in
values-*/strings.xml continue to override the library defaults at runtime.

Fixes CI failures reported in PR #3371.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude 223458c3c1 feat: complete translations for 9 community-started locales
All 9 locales now have 0 missing translatable strings:
- Swahili (sw): ~2604 strings added
- Bengali Bangladesh (bn-rBD): ~2530 strings added
- Esperanto (eo, eo-rUY): ~2626 strings added, eo-rUY mirrored from eo
- Tamil (ta, ta-rIN): ~2683 strings added, ta-rIN mirrored from ta
- Latvian (lv-rLV): ~2823 strings added
- Uzbek (uz-rUZ): ~2900 strings added (fixed unescaped apostrophes in loan words)
- Serbian (sr-rSP): ~2931 strings added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude 843d39675d feat: fill translation gaps for fa, in, ru, ru-rUA locales
- Farsi (fa): 19 missing strings now complete
- Indonesian (in): 9 missing strings (push notification UI) now complete
- Russian (ru): 144 missing strings + 1 plural now complete
- Russian Ukraine (ru-rUA): 467 missing strings + 1 plural now complete

All four locales now show 0 missing translatable strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude bd86d6af20 fix(i18n): shorten over-long translated strings across 23 locales
266 string replacements across 41 locale files to reduce UI overflow risk.
Strings shortened to fit labels and menu items; technical terms (relay,
DM, NIP, Tor, pubkey, Cashu) kept as-is per project convention.

Languages updated: ar, cs, de, el, es, fa, fi, fr, hi, hu, in, it, nl,
pl, pt-BR, pt-PT, ru, sl, sv, sw, ta, th, uk, uz + regional siblings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:53 +00:00
Claude 9d41793a43 fix: correct XML escaping and type mismatches in locale string resources
- Escape unescaped `?` in calendar_rsvp_maybe_prefixed (it, ru, ru-rRU,
  ru-rUA) — Android treats `?` at string start as a theme-attr reference
- Escape unescaped `@` in my_name (fi, tr, tr-rTR) and
  quick_action_copy_user_id (zh) — same issue with `@` references
- Convert <plurals> to <string> for accounts_found, num_selected,
  follow_accounts in ru/ru-rRU/ru-rUA — English source uses <string>,
  so type-mismatched <plurals> were silently removed by AAPT2
- Fix orphaned key in el-rGR: error_parsing_json_from_lightning_
  address_check_the_user_s_lightning_address_with_user renamed to
  correct _setup_with_user suffix matching the English source

All locale files now build without errors or warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:52 +00:00
Claude a7f6ba0ef0 feat: complete translations for 14 remaining locales
Fill all missing strings in Arabic (ar-rSA), Greek (el-rGR), Persian
(fa, fa-rIR), Finnish (fi-rFI), Indonesian (in, in-rID), Italian
(it-rIT), Japanese (ja, ja-rJP), Korean (ko-rKR), Russian (ru, ru-rRU,
ru-rUA), Thai (th, th-rTH), Turkish (tr, tr-rTR), Ukrainian (uk,
uk-rUA), Vietnamese (vi-rVN), and Traditional Chinese Taiwan (zh-rTW).

Most locale files now have all 2961 strings matching the English source.
Missing strings fall back to English per Android resource resolution.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:51 +00:00
Claude bff80daa94 feat: complete translations for 26 locale files
Fill all missing strings in cs, cs-rCZ, de, de-rDE, es, es-rES, es-rMX,
es-rUS, fr, fr-rCA, fr-rFR, hi-rIN, hu, hu-rHU, nl, nl-rBE, nl-rNL,
pl-rPL, pt-rBR, pt-rPT, sl-rSI, sv-rSE, zh, zh-rCN, zh-rHK, zh-rSG.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hfm5Lf3FfwDgPn5oiMVqUo
2026-06-26 00:10:51 +00:00
Vitor PamplonaandGitHub 74446e39cd Merge pull request #3373 from vitorpamplona/claude/adoring-shannon-h3pkfe
Render zap receipts as full notes in MultiSetCompose
2026-06-25 20:07:03 -04:00
Claude 1614e01f0e feat: render a lone zap notification as a full note, not a bare card
The single-zap/single-nutzap branch of MultiSetCompose dropped the bare
RenderLnZap/RenderNutzap activity card straight into the feed, with no
author column on the left, so it didn't read like the surrounding notes.

Reuse NoteCompose on the zap-receipt note instead: it wraps the same zap
card as the note body and gives it the standard note chrome — author
picture on the left, username header, reactions row. Padding is handed to
NoteCompose for this branch so it isn't doubled against the card column.

NoteCompose's author column previously resolved baseNote.author, which for
a kind-9735 zap receipt is the recipient's lightning provider (the signer),
not the zapper. Resolve the sender from the embedded zap request the same
way the thread's master note already does, so the picture on the left is
the actual zapper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cg8mHZDCfGmGAVDaeJxPFz
2026-06-26 00:02:37 +00:00
Vitor PamplonaandGitHub 97efa23548 Merge pull request #3372 from vitorpamplona/claude/elegant-ride-orwxko
Scale outline gem icon to match filled gem size
2026-06-25 20:02:32 -04:00
Claude 0ae1cb03c4 fix: enlarge always-on notification gem icon to match other icons
The outlined service gem was authored at ~half the 512x512 viewport size,
so it rendered noticeably smaller (both width and height) than every other
notification small icon, which fill the viewport. Wrap the path in a group
that scales it ~1.8x about its centre, leaving a margin so the stroke stays
within bounds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SVA2C5FyYWSmU7EzdFaAD2
2026-06-25 23:55:19 +00:00
Claude 6b3e216ea3 fix: let Save commit a typed-but-unadded Cashu mint
When adding a Cashu wallet, the mint URL typed into the input field was
only committed to the mint list when the user pressed the "+" button. The
Save button was gated on the list being non-empty, so a user who typed a
mint, chose a key option, and tried to Save found the button disabled with
no obvious reason — they had to discover the "+" button first.

Save now folds a pending mint from the input field into the list, making
"+" optional. The button also enables when the input is non-blank.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VX5wkwXbvoQpadALx1ZVjk
2026-06-25 23:00:06 +00:00
Vitor PamplonaandGitHub 82e1b99036 Merge pull request #3370 from vitorpamplona/claude/laughing-bardeen-amkvlr
Render single zaps/nutzaps as large cards in MultiSetCompose
2026-06-25 18:35:31 -04:00
Claude 91883da4ab fix: stop nutzaps from rendering twice in the notification feed
NutzapEvent was missing from the textNoteCards exclusion filter in
convertToCard, so every nutzap was both grouped into the MultiSetCard /
NutzapUserSetCard path AND fell through to a standalone NoteCard, which
renders kind 9321 as a big RenderNutzap card. The result was a duplicate:
the same nutzap appeared once in the grouped card and once as an
individual note — the way a reply would.

LnZapEvent is already excluded for exactly this reason; nutzaps were
simply overlooked when the nutzap grouping was added. Exclude NutzapEvent
too so it only renders through the grouped path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8ZruubvykNhfmrJXJA8s7
2026-06-25 22:29:58 +00:00
Claude a83db4c18f feat: render a lone zap/nutzap notification as a large activity card
A MultiSetCard bundles many notifications on a post into one compact stream
of icons, which is great when there are several. But a freshly-arrived zap
is usually alone in its own card (the additive feed path builds a
single-item MultiSetCard, and only a full rebuild groups same-post
notifications together), so it rendered as one tiny gallery icon.

When a MultiSetCard carries a single lightning zap or nutzap and nothing
else, render the existing large activity card (RenderLnZap / RenderNutzap)
— the same big, gradient "appreciation" display already used for onchain
zaps and the thread view — instead of the one-icon gallery. As soon as a
rebuild groups several notifications onto the same post (size > 1), it
falls back to the compact gallery as before.

This is purely a rendering decision in MultiSetCompose; the card-building
and grouping logic is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X8ZruubvykNhfmrJXJA8s7
2026-06-25 22:05:49 +00:00
Vitor PamplonaandGitHub df21563b11 Merge pull request #3367 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 17:31:32 -04:00
vitorpamplonaandgithub-actions[bot] d841277978 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 21:25:00 +00:00
Vitor PamplonaandGitHub c7e859a9a2 Merge pull request #3369 from vitorpamplona/claude/serene-clarke-19cbbz
Shift Cashu icon left and tint orange in nutzap displays
2026-06-25 17:23:28 -04:00
Claude f4ee8aac44 feat(ui): tint nutzap cashu icon orange in notification and reaction galleries
The cashu outline icon next to author avatars in the nutzap galleries
rendered in the default content color, while the lightning equivalent
(ZappedIcon) uses BitcoinOrange. Tint the cashu mark BitcoinOrange in the
reaction-row gallery (NutzapGallery), the notification multi-set gallery
(MultiSetCompose), and the notification user-set card (NutzapUserSetCompose)
so nutzaps read as value transfers consistent with lightning zaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:22:10 +00:00
Claude c3a57f94a6 fix(icons): nudge Cashu icon another 0.3 left
Shift the mark a further 0.3 units left (net -1.6 from the original).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:16:56 +00:00
Claude 1ea1b369ea fix(icons): nudge Cashu icon another 0.1 left
Shift the mark a further 0.1 units left (net -1.3 from the original).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:13:35 +00:00
Claude 7b3c5325ff fix(icons): ease Cashu icon shift back to 5% left
10% felt over-shifted; move the mark right by 1.2 units so the net offset
from the original is -1.2 (5% of the 24x24 viewport) instead of -2.4.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:09:30 +00:00
Claude 782935320e fix(icons): recenter Cashu outline icon by shifting it 10% left
The cashew + pixel-shades mark was visually weighted to the right of the
24x24 viewport. Shift every x-coordinate in both paths (the stroked cashew
body outline and the filled pixel sunglasses) by -2.4 units so the drawing
sits balanced within the icon bounds. Shape is unchanged; only position.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QEFq9cUPECuB8F56sTtWXu
2026-06-25 21:07:04 +00:00
Vitor PamplonaandGitHub d6e2d45eca Merge pull request #3368 from vitorpamplona/claude/eager-albattani-p3sxmq
Add Onion-Location support for transparent Tor routing
2026-06-25 16:57:48 -04:00
Claude e59babe085 fix: three bugs in onion-location routing
- OnionLocationCache: add 24-hour TTL so stale .onion mappings expire
  instead of permanently breaking Tor connectivity when a server rotates
  its onion address

- OnionLocationInterceptor: demote from network to application interceptor
  so chain.request().url.host is always the original clearnet hostname.
  As a network interceptor it ran after OnionUrlRewriteInterceptor had
  already rewritten the host to .onion, causing cache refreshes from
  onion-routed responses to be stored under the wrong key and never used

- OnionUrlRewriteInterceptor: make http/https scheme mapping explicit and
  symmetric with the ws/wss arm; document that http:// onion for an
  https:// clearnet host is intentional (Tor circuit provides E2E security)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 20:16:01 +00:00
Claude 5e831cadb4 test: verify OkHttp accepts .onion pseudo-TLD in toHttpUrl()
Documents that OnionUrlRewriteInterceptor's runCatching { toHttpUrl() }
call succeeds for Tor v2/v3 .onion hosts so a future OkHttp upgrade that
breaks .onion parsing is caught before the feature silently regresses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 19:45:40 +00:00
Vitor PamplonaandGitHub f5e6a251f7 Merge pull request #3365 from vitorpamplona/claude/happy-brown-tlu9nq
Extract AddRecommendationSection and WalletTypeCardContent composables
2026-06-25 15:23:15 -04:00
Claude 9b3e51f978 feat: passive Onion-Location discovery and .onion URL rewriting via OkHttp
Adds three components wired into both OkHttp factory chains (relays and
media/NIP-11):

- OnionLocationCache: ConcurrentHashMap<host, onionUrl> shared across
  all OkHttp clients. No Nostr-specific protocol changes needed.

- OnionLocationInterceptor (network interceptor, always active): reads
  the Onion-Location HTTP header from every response — WebSocket 101
  handshakes, NIP-11 documents, image servers, anything — and populates
  the cache. Discovery is a zero-cost by-product of traffic that already
  happens.

- OnionUrlRewriteInterceptor (application interceptor, Tor clients only):
  on outbound requests, checks the cache for the target host and rewrites
  to the .onion address when available. Derives the correct ws/wss scheme
  from the cached Onion-Location URL scheme so TLS expectations match.

First connection goes clearnet (or Tor-to-clearnet), populating the
cache. The next reconnect transparently uses the .onion address so Tor
connections avoid exit nodes entirely. Network transition reconnects
(already handled by RelayProxyClientConnector) retry with the cached
.onion on the fresh circuit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkQwqqjjDJmeAChYRK4tuV
2026-06-25 19:06:59 +00:00
Claude 4540b1d6de fix: extract HeaderRowTitle to reduce Compose lambda MAXLOCALS in OnchainSection
HeaderRow's Row content lambda was generating MAXLOCALS=74, causing
GC overhead OOM in the Kotlin daemon during parallel compilation.
Extracting the Column block into a named HeaderRowTitle composable
reduces the Row lambda to two direct calls, cutting its bytecode size.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 18:42:51 +00:00
Vitor PamplonaandGitHub 48b9671fd5 Merge pull request #3366 from vitorpamplona/claude/favorite-web-apps-review-cn82yb
Rename web/napplet screens and controllers; introduce HostProfile
2026-06-25 14:28:59 -04:00
Claude 7d163b89c9 refactor: replace napplet host websiteMode boolean with a HostProfile type
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
2026-06-25 18:19:16 +00:00
Claude 6437391df8 fix: extract large Compose lambdas to reduce OOM in Kotlin compiler
WalletTypeCard$lambda$0 (AddWalletScreen.kt) was generating a bytecode
method with MAXLOCALS=66, causing the JVM bytecode optimizer to exhaust
memory when three compile tasks ran in parallel on CI.

Extract the Card content into WalletTypeCardContent and extract the
add-recommendation item block in CashuMintRecommendationsScreen into
AddRecommendationSection (moving newRecommendationInput state there).
Both changes split large generated methods into smaller ones, reducing
per-task memory pressure in the Kotlin daemon.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MiKQ1CzErYcUBEbPrRudZQ
2026-06-25 17:53:19 +00:00
Vitor PamplonaandGitHub 418ea3fc46 Merge pull request #3362 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 12:04:51 -04:00
Claude 9a064984a5 refactor: rename web-app surfaces to distinguish them from the native App Store
The in-app "app" surfaces had colliding, sometimes inaccurate names. NIP-89
native apps showed as "Apps" while the in-app favorites showed as "Favorite
apps", and the single-app host screens were named `FavoriteWebAppScreen` /
`FavoriteNappletScreen` even though they open any url/coordinate (favorited or
not) and the "Napplet" one also renders nSites.

Establish one taxonomy:
- Native app store -> "App Store" (NIP-89), in-app Nostr web client -> "Web app",
  plus the existing nApplet / nSite. "Favorite" is now only the star toggle and
  the pinned grid, not a screen.
- Code axis: `WebApp` (url-based) and `NostrApp` (coordinate-based nSite/nApplet).
  The cross-process sandbox infra (`napplet/`, `nappletHost/`) keeps "Napplet".

Renames:
- Routes `FavoriteWebApp`/`FavoriteNostrApp` -> `WebApp`/`NostrApp`
- Screens `FavoriteWebAppScreen`/`FavoriteNappletScreen` -> `WebAppScreen`/`NostrAppScreen`
- Controllers `EmbeddedBrowserController`/`EmbeddedNappletController`
  -> `EmbeddedWebAppController`/`EmbeddedNostrAppController`
- Factory `acquireBrowser`/`acquireNapplet` -> `acquireWebApp`/`acquireNostrApp`
- Model `FavoriteApp.WebUrl` -> `FavoriteApp.WebApp`; `WebUrlNetworkRegistry`
  -> `WebAppNetworkRegistry`
- Strings: `software_apps`/`route_software_apps` "Apps" -> "App Store";
  `favorite_apps_empty` reworded to name web app / nApplet / nSite

Persistence is untouched: favorite id prefixes ("url:"/"nostr:"), DataStore
names ("favorite_apps"/"weburl_network"), and serialized type tags ("url"/
"nostr") are all kept stable. Taxonomy documented in
amethyst/plans/2026-06-25-web-app-naming.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HrxLCMcQADUPnm8Sj9ZJ63
2026-06-25 16:01:44 +00:00
vitorpamplonaandgithub-actions[bot] 49e56da1aa chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 16:01:36 +00:00
Vitor PamplonaandGitHub 204a8e00ee Merge pull request #3364 from vitorpamplona/claude/modest-lovelace-35fe6k
Fix bottom sheet grabber positioning and add console toggle state
2026-06-25 11:59:24 -04:00
Claude 3836cf4b88 fix: console UI — switch in top sheet, grabber at panel top when open
- TopControlSheet: replace SheetItem with SheetSwitchItem for the
  console row, adding a Switch that reflects the current
  consoleShowing state; add consoleShowing param and wire it from
  EmbeddedTabLayer
- BottomConsoleSheet: move the grabber Column before AnimatedVisibility
  so it sits at the top of the expanded panel (standard bottom-sheet
  handle behaviour) and animates upward as the console opens

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GxQdArv6h6SezciUWeoR38
2026-06-25 15:24:23 +00:00
Vitor PamplonaandGitHub bdd8041776 Merge pull request #3363 from vitorpamplona/claude/great-babbage-d0hgki
Reorganize navigation drawer and settings menu items
2026-06-25 10:23:06 -04:00
Claude 8978e0d023 feat: reorder settings sections and drop API-30 gate on drawer Browser
Reorder Account Settings: group network (relays/sync/follows, media/nests),
interactions (reactions/zaps), media playback, identity (DVMs/badges/payments),
then safety/i18n.

Reorder App Settings: group UI (privacy/prefs), composition, personalization
(home/reactions/bottom-bar/profile), then advanced (calendar/OTS/namecoin).

Also drop the now-redundant Build.VERSION_CODES.R gate on BROWSER in
DrawerNavigateItems, matching the fix landed in main (PR #3361).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 14:09:53 +00:00
Vitor PamplonaandGitHub 156f205801 Merge pull request #3359 from vitorpamplona/claude/quirky-fermat-cwxjy6
Add theme preference support to napplet/browser WebViews
2026-06-25 10:07:53 -04:00
Claude dfe74b983e style: apply spotless formatting to NavBarItem.kt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 13:54:59 +00:00
Claude b4a68f4b78 feat: reorder drawer menu items
Move BROWSER to the Navigate section (between VIDEO and DISCOVER).
Reorganise Feeds into logical clusters: content types first, then
live/social, communities/chats, calendar, apps/tools, packs, misc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdbTQuhuk2g5XGWEqmKLRz
2026-06-25 13:54:59 +00:00
Claude 46ecde05f2 chore: merge main — resolve PreviewUrl conflict, prefer imported Route
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:53:39 +00:00
Vitor PamplonaandGitHub 7e0bc87dc7 Merge pull request #3361 from vitorpamplona/claude/browser-default-new-users-o5t3kw
Remove API 30+ requirement from Browser feature
2026-06-25 09:50:54 -04:00
Claude 955688c3df chore: spotless import reordering from main merge
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0198rKcuv32DEoUPpLqsBYbx
2026-06-25 13:38:15 +00:00
Claude 8ea1dcb02f fix: drop the API-30 gate on the Browser tab
The Browser tab is a launcher: each opened site loads full-screen in its
own direct-WebView NappletBrowserActivity, which has no API-30 dependency
(it never uses SurfaceControlViewHost and only feature-detects WebView
capabilities). The API-30 gate genuinely belongs to the *embedded*
favorite-app tabs (NappletBrowserService → privacy-sandbox surface), so
keep FAVORITE_APPS gated and remove the gate from BROWSER:

- BROWSER is now unconditional in the default bottom bar and the drawer.
- BrowserScreen no longer shows the "unsupported" fallback below API 30.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRdmDri69mLJiHpJQpJU73
2026-06-25 13:29:25 +00:00
Claude c5193d268e chore: merge main — keep both theme and isFavorite params in NappletBrowserActivity
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
2026-06-25 13:28:25 +00:00
Vitor PamplonaandGitHub c1da1e3ce1 Merge pull request #3360 from vitorpamplona/claude/confident-bohr-fgk8h8
Clean up imports: organize and remove unused declarations
2026-06-25 09:23:02 -04:00
Claude 156aea632c fix: spotless import ordering and remove fully-qualified class name
Move java.* imports after kotlinx.* imports in MainActivity and
BouncingIntentNav per ktlint ordering rules. Replace fully-qualified
Route reference in PreviewUrl with an import.
2026-06-25 13:22:04 +00:00
Vitor PamplonaandGitHub db13633fcc Merge pull request #3315 from LubuSeb/lubu/url-comments-304
Add URL-scoped comment feeds
2026-06-25 09:13:19 -04:00
Claude e62f7a4b30 feat: enable algorithmic darkening for web content in napplet WebViews
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
2026-06-25 13:08:56 +00:00
Vitor PamplonaandGitHub 2f240e0b87 Merge pull request #3358 from vitorpamplona/claude/always-on-service-logo-t2d2zy
Use hollow gem icon for relay service notification
2026-06-25 08:53:54 -04:00
Claude daf6a2d022 fix: resolve SYSTEM theme to concrete DARK/LIGHT before crossing process boundary
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
2026-06-25 12:53:03 +00:00
Vitor PamplonaandGitHub 4b71649fcf Merge pull request #3357 from vitorpamplona/claude/fervent-darwin-r8i6dw
Add favorite toggle to web browser and embedded napplets
2026-06-25 08:45:00 -04:00
Vitor PamplonaandGitHub dda8f2d3f6 Merge pull request #3354 from vitorpamplona/claude/funny-clarke-nrzpq4
Extract MintPickerItem composable in CashuWalletScreen
2026-06-25 08:41:19 -04:00
Vitor PamplonaandGitHub 09ee225bf7 Merge pull request #3356 from davotoula/fix/note-grid-layout-issues
Fix/note grid layout issues
2026-06-25 08:38:00 -04:00
davotoula 020c08d561 Code review:
- reuse mediaSizingModifier in GifVideoView
2026-06-25 13:32:56 +02:00
Claudeanddavotoula 2ec072419a fix: keep animated media inside its gallery grid cell
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
2026-06-25 13:29:06 +02:00
davotoula d71c035669 fix(napplet): log failed temp-file cleanup in NappletBlobCache.put 2026-06-25 12:05:13 +02:00
David KasparandGitHub 67ffbaec86 Merge pull request #3355 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 09:21:57 +01:00
davotoulaandgithub-actions[bot] 8d036e0ac0 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 08:12:20 +00:00
David KasparandGitHub 0f587eb918 Merge pull request #3351 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-25 09:09:55 +01:00
Claude 50138fe6cb feat: distinct status-bar icon for the always-on relay service
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
2026-06-25 03:27:36 +00:00
Claude 5662c89d11 fix: extract ConsoleLogRow to fix Kotlin compiler OOM in BottomConsoleSheet
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
2026-06-25 02:45:39 +00:00
Claude fd1c41b8bb feat: default bottom bar to Home, Messages, Wallet, Browser, Notifications
Change the default bottom navigation for new users from
Home/Messages/Shorts/Discover/Favorite Algo Feeds/Notifications to
Home/Messages/Wallet/Browser/Notifications. The Browser entry is gated
to API 30+ since it renders a cross-process surface, matching the
drawer's existing gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRdmDri69mLJiHpJQpJU73
2026-06-25 02:40:03 +00:00
Claude 85388e3c4b feat: pass theme preference to napplet browser and activity surfaces
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
2026-06-25 01:38:11 +00:00
Claude d33d1e36fd feat: move favorite to pull-down sheet, remove from OmniBar
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
2026-06-25 01:16:06 +00:00
Claude fe392402c3 fix: extract MintPickerItem to fix Kotlin compiler OOM in MintPicker
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
2026-06-25 01:03:29 +00:00
vitorpamplonaandgithub-actions[bot] 443bfba65c chore: sync Crowdin translations and seed translator npub placeholders 2026-06-25 00:31:29 +00:00
Vitor PamplonaandGitHub b1aef7d6ac Merge pull request #3353 from vitorpamplona/claude/serene-hypatia-48qdoz
Add JavaScript console logging to embedded and full-screen browsers
2026-06-24 20:29:33 -04:00
Vitor PamplonaandGitHub 2d1e9a9e1f Merge pull request #3352 from vitorpamplona/claude/eloquent-shannon-0fzkag
Implement tiered memory trimming with debug UI and feed size limits
2026-06-24 18:21:12 -04:00
Claude f2f80cd88d fix: stop clearing Robohash/richtext caches on every app background
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
2026-06-24 22:00:52 +00:00
Claude ea2dbd9a77 fix: keep lastNotes intact on CardFeedContentState.trimToSize()
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
2026-06-24 21:42:14 +00:00
Claude e17c95eda1 fix: clear lastNotes on CardFeedContentState.trimToSize() to release Note refs
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
2026-06-24 21:35:14 +00:00
Claude 101ca698d9 fix: polish BottomConsoleSheet UI
- 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
2026-06-24 20:57:07 +00:00
Claude 57a63bd4b2 fix: stop wiping relay info cache on every app background
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
2026-06-24 20:44:00 +00:00
Claude 45f1fd91a3 feat: add console.log panel accessible from top pull-down control sheets
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
2026-06-24 20:34:43 +00:00
Claude 5435345f7d feat: trim richtext, robohash, and relay-info caches under memory pressure
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
2026-06-24 20:11:28 +00:00
Claude 8407684d9d feat: trim Coil memory cache and ExoPlayer warm pool under memory pressure
- 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
2026-06-24 20:11:28 +00:00
Claude 5b5bc5c70c fix: raise critical memory feed trim size from 50 to 200
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>
2026-06-24 20:11:28 +00:00
Claude 057b6266d9 feat: trim feed lists to 50 items under critical memory pressure
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>
2026-06-24 20:11:27 +00:00
Claude 4eb195fa5e fix: reorder pruning tiers in MemoryTrimmingService
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>
2026-06-24 20:11:27 +00:00
Claude 281955fb17 fix: move cleanObservers to Tier 1 in MemoryTrimmingService
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>
2026-06-24 20:11:27 +00:00
Claude f038e54fa2 feat: scale LocalCache pruning aggressiveness to OS memory-pressure level
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>
2026-06-24 20:11:27 +00:00
Claude 21e61e48ae feat: add debug-only memory usage chip to top nav bar
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>
2026-06-24 20:11:01 +00:00
Vitor PamplonaandGitHub 6539841e5e Merge pull request #3350 from vitorpamplona/claude/jolly-mccarthy-jyeu87
Browser: add visit history, omnibox suggestions, and favicon capture
2026-06-24 14:46:58 -04:00
Vitor PamplonaandClaude Opus 4.8 cad987a99e fix(embed): napplet/nsite load overlay + draw it over the surface
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>
2026-06-24 14:35:44 -04:00
Vitor PamplonaandClaude Opus 4.8 fb4a2e0858 fix(browser): recover embedded web-app tab stuck on about:blank
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>
2026-06-24 14:03:33 -04:00
Claude 2df0c4d6bb feat: favicons in bottom nav + 3-dot menu on recent rows
- 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
2026-06-24 16:04:46 +00:00
Vitor PamplonaandGitHub db99891c27 Merge pull request #3349 from vitorpamplona/claude/epic-carson-wjc0xm
Fix bottom bar fallback to use default entries instead of empty list
2026-06-24 11:43:36 -04:00
Claude 4f6a21c55d feat: favorites-first browser home + recents, with captured favicons
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
2026-06-24 15:35:40 +00:00
Claude 851fb82735 fix: only reset bottom bar to defaults on parse errors, not intentional empty selection
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>
2026-06-24 15:32:18 +00:00
Claude 4a907c750c fix: reset bottom bar to defaults when parsed config has 0 items
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>
2026-06-24 15:14:22 +00:00
Claude 7a03a47d1d feat: omnibox autocomplete, visit history, and in-page address bar for the browser
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
2026-06-24 15:02:01 +00:00
Claude 4e6f6c104b fix: improve browser omnibar URL handling and keyboard behavior
- 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
2026-06-24 14:25:45 +00:00
Vitor PamplonaandGitHub 048439201e Merge pull request #3347 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-24 10:05:25 -04:00
vitorpamplonaandgithub-actions[bot] 7b890a0eff chore: sync Crowdin translations and seed translator npub placeholders 2026-06-24 14:01:39 +00:00
Vitor PamplonaandGitHub f298750b79 Merge pull request #3348 from vitorpamplona/claude/webview-menu-custom-url-ikrgbz
Add full-screen direct-WebView browser and embedded napplet/nSite tabs
2026-06-24 09:58:59 -04:00
Claude 01ba2dc909 docs: document the final embedded-tab/browser/IME napplet architecture
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
2026-06-24 13:48:41 +00:00
Claude 3460c58274 feat: show a back arrow in the Browser top bar when pushed from the drawer
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
2026-06-24 13:33:21 +00:00
Claude 8b9fa10a1b style: standardize the two top-sheet twins (spacing, colors, Tor switch)
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
2026-06-24 13:20:25 +00:00
Vitor PamplonaandGitHub 3fa17e1cb7 Merge pull request #3346 from greenart7c3/claude/nip-2381-bunker-connect-psiq2t
NIP-46: Add optional client metadata to connect requests
2026-06-24 08:58:28 -04:00
Claude da4207319b fix(nip46): pin client metadata to 4th connect param, backfill empties
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
2026-06-24 12:08:06 +00:00
Claude 4ae2debd6b refactor(cli): drop client-metadata log from bunker connect handler
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-24 11:28:14 +00:00
Claude 22c8c45190 fix: harden embedded napplet/nsite/browser hosts against audit-found races and IME bugs
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
2026-06-24 04:27:50 +00:00
Claude 5eb39b141a refactor: session-scope NappletHostService so multiple napplet/nsite tabs are correct
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
2026-06-24 04:00:54 +00:00
Claude 09cd69ba7c feat: soft keyboard in embedded napplet/nsite tabs (same proxy, via the shell)
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
2026-06-24 03:50:47 +00:00
Claude e6bbecdea2 refactor: embedded keyboard to Flutter's editing-state model (batched, full coverage)
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
2026-06-24 03:44:15 +00:00
Claude f0d5e7a626 feat: soft keyboard in the embedded browser via a host-window input proxy
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
2026-06-24 03:13:25 +00:00
Claude e3e2482954 feat: preload bottom-bar tabs at startup so the first tap is instant
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
2026-06-24 02:24:26 +00:00
Claude 408eb3a948 refactor: scope embedded browser sessions per-tab in the shared service
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
2026-06-24 02:02:37 +00:00
Claude b474869213 fix: second embedded browser/napplet tab no longer blacks out the first
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
2026-06-24 01:31:15 +00:00
Claude 984c4e4e8c fix: embed surface no longer blacks out on double-tap / first open
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
2026-06-24 01:10:18 +00:00
Claude a0ad9082e5 fix: don't stretch the full-screen pull-down grabber across the whole width
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
2026-06-24 01:02:27 +00:00
Claude 41843dd8e8 fix: stop bottom bar resetting on upgrade; make pull-tab a dismissable drawer
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
2026-06-24 00:32:44 +00:00
Claude ba2af75b4c feat: replace full-screen sandbox corner chip with top pull-down sheet
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
2026-06-24 00:10:29 +00:00
Claude af9e8b885d refactor: apply embedded-tab audit fixes (durability, perf, leaks, cleanup)
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
2026-06-24 00:05:34 +00:00
Claude 89a32e078f fix: audit batch 1 — drop diagnostics, browser foreground heartbeat, Tor host key
- 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
2026-06-23 23:51:18 +00:00
Claude 7d5f68d115 Merge remote-tracking branch 'origin/main' into claude/webview-menu-custom-url-ikrgbz 2026-06-23 23:41:38 +00:00
Claude 9ebe16d7a1 feat: top pull-down control sheet for embedded tabs
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
2026-06-23 23:38:10 +00:00
Claude 90150e72e4 fix: upgrade privacysandbox.ui to alpha17 (embedded drag/scroll input)
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
2026-06-23 23:16:02 +00:00
Claude 1531f3baad feat: direct-WebView browser activity (scroll, zoom, keyboard)
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
2026-06-23 22:46:31 +00:00
Claude b8786091dd fix: no black flash when switching between embedded tabs
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
2026-06-23 22:25:44 +00:00
Claude 41376bac71 fix: restore input on embedded surfaces; controls in a slim bar
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
2026-06-23 22:01:11 +00:00
Claude c44a18c803 fix: embedded surface scrolling, occluded chrome, and black load flash
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
2026-06-23 21:47:54 +00:00
Claude cb8bbad38d feat: floating control puck for running app surfaces + remember Tor per web client
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
2026-06-23 21:29:15 +00:00
Claude 2115129a72 chore: add zoom diagnostic to embedded browser
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
2026-06-23 21:03:17 +00:00
Claude 0b5d7925fe chore: temporary diagnostics for embedded browser scrolling
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
2026-06-23 20:58:09 +00:00
Claude 17c6aba934 fix: theme the sandbox WebView background instead of flashing white
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
2026-06-23 20:52:18 +00:00
Claude d99ae48847 feat: lease-expire sandbox foreground holds so a dead host can't pin the network
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
2026-06-23 20:28:36 +00:00
Claude 62f0adaa76 feat: keep Tor/relays/AUTH up while a sandbox surface is foreground
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
2026-06-23 20:06:12 +00:00
Claude a26a7c9552 fix: constrain the Tor onion to 24dp (was rendering at intrinsic size)
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
2026-06-23 19:42:31 +00:00
Claude baf5c542a0 feat: favorite tabs use the app's own icon and drop the bottom-bar label
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
2026-06-23 19:34:50 +00:00
Claude 5c37f57a31 refactor: use the Tor onion for the Tor toggle, shield only for sandbox access
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
2026-06-23 19:24:24 +00:00
Claude 2d3dd21695 fix: crash measuring embedded sandbox WebView (LayoutParams cast)
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
2026-06-23 19:19:41 +00:00
Claude 1c60760c06 refactor: full-screen browser uses the same shield = security/privacy sheet
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
2026-06-23 19:14:28 +00:00
Claude b3e5e9e693 fix: inset the browser launcher omnibox below the status bar
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
2026-06-23 18:59:30 +00:00
Claude 77e41ddc00 refactor: one consistent top bar for embedded web/nsite/napplet tabs
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
2026-06-23 18:11:17 +00:00
David KasparandGitHub 2311a4cd1b Merge pull request #3345 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 19:06:44 +01:00
Claude 601ba0a942 fix: stop embedded sandbox WebViews from double-applying system-bar insets
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
2026-06-23 17:59:05 +00:00
Claude 25b530559e Merge remote-tracking branch 'origin/main' into claude/webview-menu-custom-url-ikrgbz 2026-06-23 17:38:09 +00:00
Claude 6b0bb9e859 refactor: unify the bottom bar into one ordered list of built-ins + favorites
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
2026-06-23 17:21:52 +00:00
vitorpamplonaandgithub-actions[bot] 8ffc65d727 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 17:00:04 +00:00
Vitor PamplonaandGitHub ff2c6f6313 Merge pull request #3344 from davotoula/fix/markdown-detect-heading-after-blank-line
fix: render markdown when a heading/list follows a blank line
2026-06-23 12:58:14 -04:00
davotoulaandClaude Opus 4.8 a97529d7af fix: detect markdown when a heading/list follows a blank line
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>
2026-06-23 18:23:22 +02:00
Claude 55bc75512a feat(nip46): support optional client metadata in connect request
Implements nostr-protocol/nips#2381: a client MAY attach an optional
4th positional parameter to the NIP-46 `connect` request carrying a
JSON-stringified `{name, url, image}` object, mirroring the fields
already present in `nostrconnect://` URIs. This lets a bunker:// paired
signer show who is asking to connect.

- quartz: add BunkerClientMetadata and a clientMetadata field on
  BunkerRequestConnect; serialize it as the 4th param (omitted when
  empty) and parse it back, degrading malformed/empty JSON to null.
- quartz: NostrSignerRemote carries and sends clientMetadata on
  connect() and threads it through fromBunkerUri().
- commons: BunkerLoginUseCase.execute() accepts optional clientMetadata.
- desktopApp: advertise Amethyst's metadata on bunker login.
- cli: the receiving bunker logs the connecting client's identity
  (display-only; never gates the ACK on it, since the client pubkey is
  unauthenticated in bunker:// pairing).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PYpupiVAq4VyHDdjrYyPdi
2026-06-23 16:17:19 +00:00
David KasparandGitHub 7d77a057d6 Merge pull request #3343 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 17:16:18 +01:00
davotoulaandgithub-actions[bot] dd062861f3 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 16:14:26 +00:00
David KasparandGitHub 9c1392a2e0 Merge pull request #3341 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 17:12:37 +01:00
vitorpamplonaandgithub-actions[bot] 832951b90c chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 15:58:03 +00:00
Vitor Pamplona b7cbc03b48 Fixes the compilation error 2026-06-23 11:55:40 -04:00
Vitor Pamplona bf10498caf Merged 2026-06-23 11:50:36 -04:00
Claude e4f2c5305d feat: keep pinned embedded tabs warm via a persistent surface layer
Implements keep-warm (approach A) so a bottom-row embedded tab keeps its
full state across tab swaps, scoped to bottom-row apps per review.

Why a persistent layer: androidx.privacysandbox.ui's SandboxedSdkView
closes its session in onDetachedFromWindow, so a session torn down the
moment a tab leaves composition is unavoidable if the surface lives inside
the per-screen composable. Instead, a single app-shell overlay
(EmbeddedTabLayer) holds every warm session's SandboxedSdkView attached the
whole time — the active one positioned over the current tab's reserved
content area, the rest parked off-screen but alive. No provider changes
needed: the WebView never detaches, so its JS state survives.

- EmbeddedTabHost: process-level holder of warm sessions (keyed by
  FavoriteApp.id), the active id, and the active content bounds.
- EmbeddedSurfaceController unifies the browser + napplet controllers so the
  layer can attach/park/teardown either; the napplet controller pauses its
  applet while parked (onHidden) and on app-background.
- FavoriteWebAppScreen / FavoriteNappletScreen no longer host the surface;
  they reserve the content area (reporting window bounds) and drive the warm
  controller. The trusted napplet chrome stays in the main process.

Scope (per review): only bottom-bar favorites stay warm — a tab whose app
isn't a bottom-bar favorite is evicted (restarted) when it leaves
(EmbeddedTabHost.retainOnly, driven by the settings list). Genuine memory
pressure (onTrimMemory) drops all warm sessions; mere backgrounding does not.

Needs on-device verification of the surface overlay (alignment, touch
pass-through to the bars, warm re-show) — validated here by build + assemble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 15:32:15 +00:00
Claude b373a297b6 refactor: configure favorite-app bottom-bar tabs from the settings page
Reworks how favorites get into the bottom bar, per review: instead of a
grid Pin/Unpin that auto-appended to the bar, favorites are now activated
as a dedicated "Favorite apps" section in the bottom-bar settings page —
kept separate from the built-in destinations because favorites are dynamic
data, not the fixed NavBarItem enum.

- UiSettings/UiSettingsFlow/UISharedPreferences gain bottomBarFavoriteIds
  (a device-local list of FavoriteApp ids), persisted alongside the
  existing bottomBarItems.
- BottomBarSettingsScreen gets a "Favorite apps" section: one toggle per
  favorite to activate/deactivate it as a bottom-bar tab.
- AppBottomBar renders the favorite tabs from that settings list instead of
  a registry-side pinned set.
- FavoriteAppsRegistry drops the pinned-set machinery; the grid drops its
  Pin/Unpin item. The "All apps" grid remains a built-in destination.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 15:22:52 +00:00
Vitor Pamplona 1eca812aec Fixes tests 2026-06-23 11:08:24 -04:00
Vitor Pamplona 1365be12a5 Fixes tests 2026-06-23 11:05:04 -04:00
Claude e82f1057c8 feat: embed nsites/napplets as in-process tabs; pin them to the bottom bar
Completes the favorites system: a favorited nsite/napplet can now be
pinned to the bottom bar and render as an embedded, swap-in-place tab —
no longer only a full-screen activity launch.

New sandbox surface (:napplet, keyless), mirroring the browser embed:
- NappletHostService hosts the verified-blob WebView (same content server,
  shell bridge, and single launch-token broker path as NappletHostActivity)
  and ships it as a SandboxedUiAdapter surface, so applet JS still runs only
  in the keyless process — never where the keys live.
- NappletHostUiAdapter / NappletEmbedContract are the SurfaceControlViewHost
  adapter and the Messenger contract; the create-session bundle reuses
  NappletHostContract's EXTRA_* keys, so the embedded and full-screen host
  paths launch from identical, main-process-minted parameters.

Main process:
- NappletLauncher.buildLaunchParams extracts the verified param/token minting
  so both the activity intent and the embedded session share it.
- EmbeddedNappletController binds the service and attaches the surface
  (mirror of EmbeddedBrowserController).
- FavoriteNappletScreen draws the TRUSTED CHROME (sandbox shield, app name,
  "what it can access") in the main process around the surface — the sandbox
  must never draw chrome the user is meant to trust — plus a pop-out to the
  full-screen host. Capability consent still flows through the existing
  main-process broker + consent activity, unchanged and host-agnostic.

Security parity with the full-screen host:
- The applet's JS + timers are paused while the app is backgrounded
  (lifecycle ON_STOP/ON_START → MSG_PAUSE/MSG_RESUME), so an "allow always"
  napplet can't act on the user's behalf when they aren't looking.
- Granted sensitive ops (publish/upload/pay) surface a notice toast.

Both favorite kinds are now pinnable; the bottom bar routes WebUrl →
FavoriteWebApp and NostrApp → FavoriteNostrApp, each embedding in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:59:44 +00:00
Claude 0606566238 feat: pin favorite web apps as embedded, swap-in-place bottom-bar tabs
Builds on the favorites system so a favorite can live in the bottom row
as a real tab instead of only launching a full-screen activity.

- FavoriteAppsRegistry gains a pinned-ids set (persisted device-locally,
  alongside the favorites list). Only WebUrl favorites are pinnable today
  — they're the ones that embed in-process — so a pinned tab always swaps
  in place and never launches an activity from the bottom row. Removing a
  favorite unpins it.
- Route.FavoriteWebApp(url) + FavoriteWebAppScreen render the embedded
  :napplet browser surface as an in-app tab: the app bottom bar stays, so
  switching to/from it is an ordinary tab swap. A pop-out action hands the
  same URL to the full-screen BrowserHostActivity for users who want it as
  its own window.
- AppBottomBar appends pinned favorites as tabs after the built-in items,
  navigating via navBottomBar (marked a tab root, so the bar stays).
- The Favorite Apps grid gains a Pin/Unpin action for WebUrl favorites.

Known follow-ups (intentionally out of this commit): NostrApp favorites
can't embed as tabs yet (they need an embedded nsite/napplet surface in
nappletHost), and embedded tabs rebuild on return rather than staying warm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:40:51 +00:00
Claude c21b592cb1 feat: favorite web apps — launcher, full-screen host, and bottom-bar grid
Introduces a device-local "favorite apps" system that unifies nsites,
napplets, and arbitrary web clients behind one model and three
presentation shells, while removing the editable address bar from any
running app (it now lives only in the browser launcher).

Core spine:
- FavoriteApp (commons): a sealed model with two cases — NostrApp
  (nsite/napplet, keyed by addressable coordinate so it survives code
  updates) and WebUrl. napplet-vs-nsite is recomputed from the live
  event at launch, never stored.
- FavoriteAppsRegistry (amethyst): device-local, DataStore-backed,
  StateFlow source of truth; main process only, hydrated at app start.
- FavoriteAppLauncher: dispatches a favorite to its one launch path —
  full-screen BrowserHostActivity for a URL, sandboxed NappletLauncher
  (re-resolved from LocalCache) for an nsite/napplet.

Presentation:
- BrowserHostActivity: full-screen, single-app host in the main process
  that embeds the keyless :napplet browser surface. Its own task/recents
  entry (documentLaunchMode=intoExisting), no editable URL — locked to
  the app it opened with, keeping one NIP-07 trust context per instance.
- BrowserScreen is now a launcher: an omnibox that opens each URL in its
  own host activity, plus the shared favorites grid. The address bar is
  gone from the content surface.
- FavoriteAppsScreen + FAVORITE_APPS bottom-bar item: a grid of big
  launch buttons, reused inside the browser launcher.
- StaticWebsiteCard gains a header-actions slot; a star toggle on each
  nsite/napplet card pins it (strings/store stay in the app layer).

EmbeddedBrowserSurface extracts the chrome-free surface + controller
helper so the tab, the launcher, and the host activity share one piece
of cross-process glue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 14:30:49 +00:00
Vitor PamplonaandGitHub f6ea3e1999 Merge pull request #3342 from vitorpamplona/claude/funny-cannon-txzzqf
Load author relay lists for missing addressable notes
2026-06-23 09:58:49 -04:00
Vitor PamplonaandGitHub 3b969e4b71 Merge pull request #3335 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-23 09:44:56 -04:00
Claude 128eed4a25 refactor: reuse UserFinderFilterAssembler instead of duplicating relay-list logic
Replace the self-contained AddressableAuthorRelayLoaderSubAssembler
(which duplicated UserOutboxFinderSubAssembler) with a thin bridge that
injects UserFinderQueryState entries directly into the existing
UserFinderFilterAssembler.

When EventFinderFilterAssembler detects an AddressableNote stub whose
author relay list is unknown, it subscribes that author to userFinder.
UserOutboxFinderSubAssembler already handles the kind-0/10002 fetch and
relay resolution — no logic is duplicated. Subscriptions are cleaned up
when the note loads or the EventFinder key is removed.
2026-06-23 13:34:15 +00:00
Claude 785af41c99 refactor: remove dead full-screen browser path superseded by embedded surface
The full-screen NappletHostActivity browser-mode and NappletLauncher.launchBrowser
were added alongside the embedded browser but never wired into any UI. The embedded
surface (NappletBrowserService rendered via SurfaceControlViewHost) supersedes them
and draws the address bar in the trusted main process rather than inside the sandbox,
so this removes the weaker, unused surface.

Removed:
- NappletHostActivity browser-mode (setupBrowser, address bar, per-origin bridge) —
  reverted the host activity to its pre-browser state.
- NappletLauncher.launchBrowser and the EXTRA_BROWSER_MODE/EXTRA_BROWSER_URL extras.
- The now-unused nappletHost browser strings.

Kept (used by the embedded path): the broker per-origin token mint
(MSG_MINT_BROWSER_TOKEN / MSG_BROWSER_TOKEN / KEY_BROWSER_ORIGIN) in NappletBrokerService
and NappletIpc, so NIP-07 consent stays scoped per visited origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 13:12:00 +00:00
Claude d63bf87d2a feat: add in-app browser tab rendered from the keyless napplet sandbox
Adds a "Browser" navigation destination (drawer + pinnable bottom-nav item,
API 30+) that opens any URL. The page renders in the sandboxed, keyless
`:napplet` process and is streamed into the main activity as a cross-process
surface via androidx.privacysandbox.ui (SurfaceControlViewHost) — only pixels
and input cross the boundary, never the WebView's JS context or the NIP-07
bridge. The trusted address bar is drawn by the main process around the
embedded surface, so the sandbox can never spoof the URL.

NIP-07 `window.nostr` is injected the same way nSite website mode does it, but
scoped per visited origin: each origin gets its own broker-minted launch token
(keyed by the trusted source origin), so a grant to one site never leaks to
another.

- NappletBrowserService (`:napplet`): hosts the live-URL WebView, exposes it as
  a SandboxedUiAdapter, and relays the per-origin NIP-07 bridge to the broker.
- NappletBrowserUiAdapter: wraps the WebView session for privacysandbox.ui.
- NappletBrokerService: mints a per-origin synthetic identity so NIP-07 consent
  is scoped per host.
- EmbeddedBrowserController + BrowserScreen: bind the service, render the
  SandboxedSdkView, and drive the trusted address bar (navigate/reload/back/Tor).
- shim.js: a direct-bridge transport so the injected shim works in a top-level
  page that has no trusted shell parent.
- Browser nav item hidden below API 30 (SurfaceControlViewHost requirement).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgMpRcWj6y82LxLiwcuzmN
2026-06-23 03:39:06 +00:00
vitorpamplonaandgithub-actions[bot] 82a20c012a chore: sync Crowdin translations and seed translator npub placeholders 2026-06-23 03:10:29 +00:00
Vitor PamplonaandGitHub 402439f09f Merge pull request #3340 from vitorpamplona/claude/github-ci-failures-yja302
fix(build): resolve :nappletHost benchmark variant via matchingFallbacks
2026-06-22 23:08:53 -04:00
Claude 1f4ff91419 fix(build): give :nappletHost a benchmark variant for :amethyst benchmark builds
The new plain-AGP :nappletHost library only declared debug/release build
types (unlike the KMP :commons/:quartz/:nestsClient libs, which match any
build type). The :amethyst app's `benchmark` build type therefore had no
matching :nappletHost variant, breaking fdroidBenchmark/playBenchmark
resolution and Test/Build CI on main.

Declare a matching `benchmark` build type (initWith release) in :nappletHost
so the app resolves a real variant. Library AARs aren't signed, so no
signing config is needed here. :nappletHost is the only plain-AGP library
:amethyst depends on, so no consumer-side matchingFallbacks is required.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
2026-06-23 02:50:48 +00:00
Claude 4574005019 feat: fetch author relay list when addressable event is missing
When a kind-16 repost (or any event) has an a-tag pointing to an
addressable event by an unknown author (no NIP-65 relay list loaded),
potentialRelaysToFindAddress() returned an empty set and the event was
never found.

AddressableAuthorRelayLoaderSubAssembler mirrors
UserOutboxFinderSubAssembler but is driven by EventFinderQueryState.
It detects AddressableNote stubs whose author relay list is missing and
emits kind-0/10002 filters to indexer/search relays. On EOSE it calls
invalidateFilters(), causing NoteEventLoaderSubAssembler to re-run with
the now-populated outbox relays and fetch the addressable event.
2026-06-23 01:46:58 +00:00
Claude 0866d38065 fix(build): resolve :nappletHost benchmark variant via matchingFallbacks
The new plain-AGP :nappletHost library only declares debug/release build
types (unlike the KMP :commons/:quartz libs, which match any build type).
The :amethyst app's `benchmark` build type had no fallback, so Gradle could
not resolve a matching :nappletHost variant for fdroidBenchmark/playBenchmark,
breaking Test/Build CI on main.

Add `matchingFallbacks += "release"` to the benchmark build type so it falls
back to a library's release variant when no benchmark variant exists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tzXcqVUcAPhyDDFak4SQ8
2026-06-23 01:21:31 +00:00
Vitor PamplonaandGitHub 068e12b5f6 Merge pull request #3339 from vitorpamplona/claude/awesome-pasteur-xwiwad
Implement NIP-5A/5D napplet & nsite sandbox host with capability broker
2026-06-22 20:46:16 -04:00
Claude 2b80bf49e3 feat(napplet): broker the NAP inc topic bus
Adds the `inc` pub/sub bus so napplets that declare it boot and can exchange
topic events: `inc.subscribe {topic}` / `inc.unsubscribe {topic}` register
interest, `inc.emit {topic, payload}` fans out an `inc.event {topic, payload,
sender}` to OTHER subscribed napplet sessions (never echoing the sender) — the
kehto runtime's inc contract.

- Router edge ops (gated on the INC declaration alone, like identity.watch —
  no per-call consent): SubscribeInc/UnsubscribeInc/EmitInc outcomes.
- Protocol: readTopic/readPayloadRaw + encodeIncEvent.
- NappletIncBus in the broker service routes across the live napplet sessions
  (the one service every sandbox binds), keyed by reply Messenger.
- Tests for inc routing + declaration gating; updated capability/router tests
  that asserted the old "inc/theme/notify are unknown" behavior.

NOTE: napplets run foreground-only/one-at-a-time, so cross-napplet delivery is
usually a no-op in practice; the bus is correct if sessions ever overlap. It is
app-wide (not author-scoped) — a future refinement could namespace topics by
author. Unblocks feed/profile-viewer/chat/bot. See the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:37:44 +00:00
Claude 1599f34308 feat(napplet): broker the NAP notify domain
Napplets can now create/list/dismiss user-facing notifications:
`notify.create { title, body }` → `notify.created { id }` (the bespoke
past-tense reply the client listens for, not the generic `.result`),
`notify.list` → `notify.listed { notifications }`, `notify.dismiss` fire-and-forget.

- NotifyCreate/NotifyList/NotifyDismiss requests, NotifyCreated/NotifyListed
  responses, NappletNotifyGateway + NappletNotification; broker executes them
  (consent-gated, ask-once).
- Protocol encodes the past-tense reply types.
- Android: NappletNotificationStore (per-coordinate, main-process, survives
  broker rebuilds — a napplet only ever sees/dismisses its own) + a best-effort
  system-tray notification.
- Consent summary + string for notify.

Unblocks the toaster demo. See the plan doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:30:42 +00:00
Claude 4b8b2ac513 feat(napplet): broker the NAP theme domain
Napplets can now read the host's current theme via `theme.get` →
`theme.get.result { theme: { colors: { background, text, primary } } }`,
mapping it to their CSS variables. This is the universal boot gate for
real-world napplets (e.g. kehto/web's demos all `requires: theme` and abort
if shell.supports('theme') is false).

- NappletCapability gains THEME (+ NOTIFY/INC, wired in following commits);
  adds requiresConsent (false for SHELL/THEME — cosmetic/negotiation never prompt).
- ThemeGet request, Theme response, NappletThemeGateway; broker executes it
  with no consent prompt.
- Android gateway returns Amethyst's brand purple with a dark/light bg+text pair.
- Capability label/description/icon + strings for theme/notify/inc.

See amethyst/plans/2026-06-23-napplet-nap-theme-notify-inc.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-23 00:23:26 +00:00
Claude 20b42d3443 fix: prefetch off main thread; use real Tor logo in sandbox bar
- StrictMode DiskReadViolation: PrefetchManifestBlobs read context.cacheDir
  (ensurePrivateCacheDirExists touches disk) on the composition dispatcher.
  Move it into withContext(Dispatchers.IO); the prefetch was already IO-bound.
- Sandbox top-bar network indicator now uses the app's real Tor logo
  (ic_tor, copied into :nappletHost since it can't depend on :amethyst) via an
  ImageView, instead of the onion/globe emoji. Lit when routing through Tor,
  dimmed when the site loads over the open web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 23:30:30 +00:00
Claude 7eb0949e5c feat: route nSite WebView traffic through Tor by default, per-site opt-out
nSites now load over Tor by default when Tor is active — closing the gap where
only blob fetches were Tor-routed while the site's own web traffic (fetch/img/
script) went out the system network and could leak the user's IP. Users can opt
a specific site out to the open web (e.g. a site that breaks or is slow over
Tor); the choice is remembered per site and makes everything for that site
direct (web + blobs).

- NappletHostActivity sets a process-wide WebView SOCKS proxy override
  (socks5://127.0.0.1:<torPort>) for website-mode nSites, or clears it for open
  web. Best-effort + on-device-verifiable: SOCKS-over-WebView support varies by
  WebView version, so it's isolated to applyWebViewProxy() and never breaks the
  site if unsupported.
- Top-bar onion (🧅 Tor / 🌐 open web) shows the routing and toggles it; the
  dialog explains the IP-privacy trade. Shown only for nSites when Tor is active.
- NappletNetworkRegistry: main-process per-site preference (coordinate-keyed,
  DataStore-backed, Tor-default). The launcher reads it; the broker persists the
  sandbox's toggle resolved through the launch token. The key-free sandbox never
  touches it.
- Toggling persists via a new MSG_SET_NETWORK_MODE IPC, then relaunches the host
  so the proxy + content server rebuild cleanly for the new mode.
- "Open web" also routes blob fetches direct (effective proxy -1); locked
  napplets always keep Tor for blobs (no toggle exposed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 23:10:12 +00:00
Vitor PamplonaandClaude Opus 4.8 d0d1577bfe fix(napplets): remove the stray white band at the bottom of the applet WebView
Two edge-to-edge issues on the napplet/nsite host, both surfacing as a white strip below
the applet on a light page:

- Double bottom inset. The root inset listener turned the system-bar/cutout insets into
  padding but returned them un-consumed, so the child WebView (which on targetSdk 35+
  auto-applies any insets it receives to its web content) padded the bottom a SECOND time.
  Zero those types before they reach the WebView, keeping IME flowing so keyboard resize
  still works.

- Over-scroll reveal. Forcing a scroll past the content edge stretched the view and
  exposed the shell document's background behind the applet iframe. WebView.overScrollMode
  only governs the outer frame, so also inject `overscroll-behavior: none` into the applet
  document (alongside the shim) to kill the stretch at the source, theme-independently.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 19:01:42 -04:00
Claude c7d876096f feat: NIP-07 window.nostr provider for nSites
nSites now open in "website mode": a normal web app with normal network
access plus a NIP-07 window.nostr provider, so standard Nostr web apps can
"log in with Amethyst" and sign as the active user. Napplets are unchanged
(locked, declared-only sandbox).

- window.nostr (shim.js) installs only when the host sets __nappletNip07
  (website mode). getPublicKey/getRelays reuse the existing consent-gated
  identity reads; signEvent is a new sign-only op honoring the app-supplied
  created_at (no publish — the web app sends to relays itself).
- NappletRequest.SignEvent + nostr.signEvent decode; broker signs as the
  user and returns the signed event without publishing. pubkey is still
  fixed by the signer, so the app can never sign as another identity.
- Website mode: content server defers off-origin requests to the WebView
  and drops the app CSP (normal network); locked napplets keep connect-src
  'none' and 404 off-origin.
- Launcher grants IDENTITY + RELAY (consent-gated) for website mode,
  independent of the nSite's empty manifest requires.
- Consent dialog shows the kind + content preview for a sign request.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:49:58 +00:00
Claude 5e4250bfbc fix(napplets): expand the "what it can access" section straight down, not from the left
The disclosure used AnimatedVisibility's default transition and the inner servers
Column didn't fill width, so the section faded/expanded while the server list also
grew in horizontally from the left — an inconsistent, weird effect. Make the
transition explicit (fade + expandVertically/shrinkVertically anchored at Top, so
it opens/closes straight down like the card) and fill width on the servers column
so nothing slides in sideways.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:26:47 +00:00
Claude 42edc13be2 feat(napplets): back gesture pages back in the WebView, then exits to Amethyst
The sandbox host now routes the back gesture into the applet/site's own history
first: an OnBackPressedCallback calls webView.goBack() while there's history to
pop (in-page links, iframe navigations, and history.pushState all count), and
only disables itself — letting system back return to Amethyst — once the WebView
is at its first page. The callback's enabled state tracks canGoBack() via
doUpdateVisitedHistory / onPageFinished. Works with predictive back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:23:36 +00:00
Vitor PamplonaandClaude Opus 4.8 a66821d59e docs(napplets): record the per-applet storage-origin change in the security plan
Document the move from the opaque `allow-scripts`-only sandbox to a real per-applet
origin (https://<sha256(author:identifier)>.napplet.local, allow-same-origin) under
"Fixed in this pass", plus the residual risks it introduces: the load-bearing invariant
that the applet origin must stay distinct from the shell/bridge origin, persistent client
storage as a new persistence/exfil surface, the origin-id derivation, and service workers
now being reachable-but-unwired.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:20:57 -04:00
Vitor PamplonaandClaude Opus 4.8 53d6a2f8b4 fix(napplets/nsites): render real SPAs in the sandbox (blank-page + reload-loop)
Static-site / SPA nApplets & nSites (Vite/nsyte/CRA/webpack output) rendered as a
blank page, then — once that was fixed — as a fast reload-loop blink. Three layered
causes, each found on-device via logcat:

1. Sub-path serving. The applet loaded under https://napplet.local/app/, but bundlers
   emit absolute asset URLs (/assets/app.js, /fonts/…) that resolve against the origin
   ROOT, so every script/style/font 404'd. nSites are defined to be hosted at the domain
   root; serve there.

2. Opaque-origin storage. The applet ran in an `allow-scripts`-only iframe, so its origin
   was opaque ("null"): module scripts + asset fetches were CORS-blocked, and reading
   localStorage/IndexedDB/serviceWorker threw SecurityError — which crash-loops every SPA
   (gruuv: "cache version 0 < 23 → reset → reload", forever, because IndexedDB never
   worked so the version never persisted).

Fix: give each applet its OWN real, persistent, isolated origin — a per-applet subdomain
https://<id>.napplet.local (id = sha256(author:identifier)), framed by the shell with
`allow-scripts allow-same-origin`. A real origin restores localStorage/IndexedDB/SW and
makes the applet's own assets same-origin (no CORS). Isolation is preserved because the
origin is DISTINCT from the shell's: the native bridge stays origin-restricted to the
shell (napplet.local), so the cross-origin applet still can't reach it or read the shell
DOM, and per-applet subdomains keep applets' storage isolated from each other. The shell
HTML's iframe src + CSP frame-src are bound to the specific applet origin at serve time.

Also add an in-memory localStorage/sessionStorage polyfill to the injected shim as
belt-and-suspenders for any context where DOM storage is still unavailable.

By-design sandbox enforcement is unchanged and correctly blocks the rest (external CDN
scripts, direct relay WebSockets via connect-src 'none', external images) — apps must go
through the napplet SDK for those.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 18:18:56 -04:00
Claude 176c96ef01 perf(napplets): faster opens — overlap WebView init, parallel prefetch, CAS fast-serve
Three load-time wins on the nApplet/nSite open path:

1. Overlap WebView/Chromium init with the index probe. The host now creates +
   warms the WebView (and binds the broker) in onCreate, so its slow first-in-
   process init runs concurrently with the IO availability probe instead of
   serially after it; the probe just attaches the ready WebView.
2. Prefetch in parallel, index-first. NappletBlobPrefetcher downloads a
   manifest's blobs with bounded concurrency (5) and fetches index.html first,
   instead of strictly sequentially — faster first paint on cold opens.
3. Serve CAS hits without re-hashing. The content server short-circuits a
   content-addressed cache hit (verified on write, addressed by sha256) straight
   to the response, skipping the resolver's per-serve sha256 over the whole blob
   — the dominant CPU cost for large bundles. Network (cache-miss) path still
   fully verifies.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 22:07:09 +00:00
Claude 31bd34e838 feat(napplets): prefetch blobs to disk on render + loading/unavailable screen
Faster opens: while an nApplet/nSite card is on screen, eagerly download +
sha256-verify all of its blobs (Tor-routed) into a shared content-addressed
cache, so tapping Open serves from disk. Prefetch is de-duplicated (in-flight +
on-disk) and cancellation-aware (stops when the card scrolls away).

- New :nappletHost pieces: NappletBlobCache (content-addressed, multi-process-safe
  atomic store, replacing the single-process OkHttp DiskLruCache), NappletBlobHttp
  (shared Tor OkHttp + size-capped download), NappletBlobPrefetcher.
- NappletContentServer now reads the shared cache first, falling back to a Tor
  download that refills it; still re-verifies every blob on serve.
- Cards trigger prefetch via a LaunchedEffect in the StaticWebsite render fns.

Nicer launch: NappletHostActivity probes the index (resolving + caching it)
behind a loading screen (monogram + title + spinner), then shows the app, or a
clear "couldn't load — try again" screen if the publisher's servers are offline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 21:32:48 +00:00
Vitor PamplonaandClaude Opus 4.8 4b838fec72 feat(napplets/nsites): link browse feeds to the TopNav filter at the relay level
The nApplet (NIP-5D) and nSite (NIP-5A) browse feeds ignored the top-nav
follow-list selection at the relay layer: a single SingleSubEoseManager fired a
hardcoded all-authors REQ against the user's home relays, and the screen merely
re-filtered the dump client-side. Selecting Follows / a People-list never changed
what was fetched, so manifests on an author's own write relays were invisible.

Bring them to Pictures-style parity:

- Replace the SingleSubEoseManager with PerUserAndFollowListEoseManager, watching
  defaultX FollowList + the new liveX FollowListsPerRelay outbox flow and rebuilding
  the per-relay REQ on change.
- Add makeN{applets,sites}Filter dispatchers + subassemblies (Global, Authors,
  Follows, MutedAuthors). These manifests carry no topical tags, so the tag-based
  selections (hashtag/geohash/community) correctly fall through to no subscription.
- Add liveNappletsFollowListsPerRelay / liveNsitesFollowListsPerRelay (OutboxLoaderState).
- New authorOnlyRoutes spinner option set: author-based filters only (no geohash,
  hashtag, community, relay, interest-set or AroundMe — none can match these events).
- "Mine" is intercepted per-feed (SubAssembler + screen), querying the user's own
  pubkey against their outbox relays — the shared TopFilter.Mine flow resolves to
  all-follows, so it can't be used directly.

Fix a FATAL duplicate-key crash: these replaceable/addressable kinds were observed
through the versioned note store (observeEvents) and keyed by per-version event id.
Switch to observeNotes, which yields one AddressableNote per address (latest
version), keyed by the stable address (idHex) and auto-updating in place.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:06:43 -04:00
Claude 4218a7c1af refactor(napplets): extract sandbox runtime into :nappletHost module
The `:napplet` sandbox runtime (NappletHostActivity, NappletContentServer,
NappletIpc, NappletKeyActions) now lives in a new :nappletHost Android library
that depends only on :commons + :quartz — NEVER :amethyst. So the sandbox code
is compile-time incapable of importing Amethyst.instance / LocalCache / Account,
turning the "two-process, no secrets in the sandbox" rule from a convention into
a build-graph guarantee.

- New module + NappletHostContract (Intent-extra keys + broker service FQN), so
  the launcher (amethyst) and activity (module) share the launch contract with no
  dependency cycle. The activity binds the broker by class name.
- Capability labels for the "what it can access" sheet are resolved by the
  launcher (which has app resources) and passed in, so the module needs no
  capability string resources. Host-only strings moved into the module.
- amethyst depends on :nappletHost; the broker-side (NappletBrokerService,
  gateways, NappletLaunchRegistry) stays in :amethyst. Manifest declares the
  activity by FQN (keeps @style/Theme.Amethyst resolvable).
- Docs updated (CLAUDE.md + security plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:45:19 +00:00
Claude 55cbb72a99 docs(napplets): document the two-process model for future contributors/AIs
Add a heads-up to the Amethyst Application KDoc and .claude/CLAUDE.md that the
app runs in two OS processes (main + :napplet), that Android reuses the single
Application class in both, and that statics/objects (Amethyst.instance,
LocalCache, NappletLaunchRegistry) are per-process — so code must not assume
`instance` exists off the main process or share singletons across the boundary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:18:34 +00:00
Claude cdd5e916c5 fix(napplets): don't crash the :napplet process on memory-trim
Android instantiates the single Amethyst Application in every process, so the
sandbox (`:napplet`) shares it and onCreate early-returns there, leaving the
lateinit `instance` (AppModules) unset. But onTrimMemory — delivered to every
process on real devices — and onTerminate called `instance` unconditionally,
crashing the sandbox with UninitializedPropertyAccessException on a trim.
Gate both with the cached sandbox-process check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:08:20 +00:00
Claude 6d367526a5 feat(nsites): dedicated nSites browse feed (mirrors nApplets)
Add a bottom-nav nSites feed for NIP-5A static sites (kinds 15128/35128),
mirroring the nApplets feed: a follow-list FeedFilterSpinner in the top bar
(persisted as defaultNsitesFollowList + liveNsitesFollowLists author-matcher)
and rows rendered through the shared NoteCompose path, so they reuse the author
header, StaticWebsiteCard (with Open), and reaction bar.

New: NsitesScreen, NsitesTopBar, the Nsites discovery datasource trio, Route.Nsites,
NavBarItem.NSITES. Wired into AppNavigation, the relay coordinator, and the
follow-list settings persistence.

Also includes the earlier display rebrand to nApplet/nSite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 20:03:44 +00:00
Claude 8c4eae6f5a chore(napplets): rebrand display text to "nApplet" / "nSite"
User-facing strings only — "Napplet(s)" → "nApplet(s)" and the static-site
label → "nSite". Code identifiers, resource keys, CLI verbs (amy napplet/nsite),
and unrelated profile "Website" labels are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 19:52:00 +00:00
Vitor PamplonaandGitHub e5ee09f060 Merge pull request #3337 from vitorpamplona/claude/peaceful-mendel-drkneu
Show blocked/muted user indicator on profile tabs
2026-06-22 15:43:50 -04:00
Claude 8ad5244c1e feat: show blocked/muted state instead of empty feed on profile
When viewing the Notes or Replies tab of a muted/blocked user, display a
"UserBlockedFeed" screen with an Unblock button rather than loading the
LazyColumn feed (which would show only dividers after filtering removes all
hidden posts).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ChNCLiDX58paTKhReYCYq6
2026-06-22 19:23:40 +00:00
Claude ab2ca77998 fix(napplets): harden the sandbox trust boundary (identity, private lists, visibility)
Three security hardenings from the nsite/napplet review:

1. Launch-token identity binding. The broker no longer trusts the identity +
   declared capabilities sent on each IPC message from the (less-trusted)
   :napplet process. The main process now mints a random token at launch
   (NappletLaunchRegistry), hands only that to the sandbox, and resolves it back
   to the trusted identity/declared set. A compromised sandbox can act only as
   the napplet it was launched as — closing cross-napplet coordinate spoofing
   (storage + permission ledger).

2. Stop leaking private lists. identity.getMutes/getBlocked read the decrypted
   flow, which includes the user's PRIVATE mutes/blocks. Return only the events'
   public tags (MuteListEvent.publicMutes / PeopleListEvent.publicUsersIdSet).

3. Make grants visible + anti-phishing chrome. A persistent trusted sandbox bar
   (shield + name + tap for "what it can access") the applet can't draw over, and
   a live toast when a granted publish/upload/payment runs — so an allow-always
   grant can't act silently. Also keeps the host out from under the system bars.

Findings + residual risks (session-scoped grants, per-origin resource consent)
tracked in amethyst/plans/2026-06-22-napplet-nsite-security.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 18:47:19 +00:00
Vitor PamplonaandClaude Opus 4.8 6a07d21a23 Merge upstream/main into claude/awesome-pasteur-xwiwad
Brings in apps-feed-card 3-dot options menu (upstream #3336).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:37:06 -04:00
Vitor PamplonaandClaude Opus 4.8 d01455f3c9 Merge remote napplet updates into local fix branch
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:29:24 -04:00
Vitor PamplonaandClaude Opus 4.8 07d25c02db fix(napplet): serialize consent prompts so concurrent requests don't drop
When a napplet issued several consent-gated calls at once (the common case: it
reads relays + storage + identity on load), each launched a NappletConsentActivity
concurrently. The host can only show one, so the rest were delivered to the
single-top activity and silently dropped — their broker calls hung forever
(storage stuck pending; a subscription's consent lost, yielding 0 events).

Gate the consent-prompt path behind a Mutex on the (per-account, reused) broker
so prompts queue one at a time. After taking the lock, re-read the ledger so a
sibling request for the same capability honors the just-recorded grant instead
of prompting again. Only the prompt is serialized — execute() and already-granted
paths stay parallel. Per-use capabilities (payments) still re-prompt every time.

Adds a regression test asserting 5 concurrent same-capability requests yield
exactly one prompt and never two dialogs at once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:33 -04:00
Vitor PamplonaandClaude Opus 4.8 44deeb1ee0 fix(napplet): launch the sandbox host by reading shell/shim from assets
NappletHostActivity runs in the isolated `:napplet` process, which early-returns
from Amethyst.onCreate to stay key-free and so never initializes the
compose-resources Android context. `Res.readBytes` then threw
MissingResourceException, crashing the host 100% on launch (the napplet feature
could never open).

Read shell.html/shim.js straight from the APK assets (where compose-resources
packages them) via the Activity context instead of the suspending Res accessor.
NappletWebContract now exposes the relative paths + RESOURCE_ASSET_ROOT so the
paths stay single-sourced.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:28:32 -04:00
Claude c2c00e7867 fix(napplets): respect system bar / cutout insets in the host WebView
NappletHostActivity is edge-to-edge by default on recent Android, so the
sandboxed applet/nsite content drew under the status and navigation bars.
Pad the WebView by the system-bar + display-cutout insets so the content
sits in the safe area.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 18:27:04 +00:00
Vitor PamplonaandGitHub f58cda166d Merge pull request #3336 from vitorpamplona/claude/modest-ritchie-lodbn3
Add more options button to software app note rendering
2026-06-22 14:22:17 -04:00
Vitor PamplonaandClaude Opus 4.8 a277f6a65f Merge upstream/main into claude/awesome-pasteur-xwiwad
Brings in nestsClient MoqLiteSession Ended-announce race fix (upstream #3334).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 14:15:30 -04:00
Claude 554fa16743 feat: add 3-dot options menu to apps feed card 2026-06-22 17:20:10 +00:00
Claude a2eae42c07 feat(napplets): app-store-style card + icon manifest tag; demote capabilities
Redesign the shared StaticWebsiteCard (used by the feed AND the napplets browse
screen) to look like an app entry instead of a manifest dump: square app icon
(with a colored monogram fallback), name, a NAPPLET/WEBSITE type label, a short
description, and an Open button. The technical details users don't care about —
declared capabilities, Blossom servers, source URL — move behind a tap-to-expand
"What it can access" disclosure; capabilities are still re-confirmed at the
consent prompt when actually used and remain fully manageable in the permissions
screen.

Add an `icon` tag (NIP-5A/5D) end-to-end:
- quartz: IconTag + siteIcon() accessor/builder, NappletManifest.icon(), and an
  icon param on all four site/napplet build() factories (+ round-trip test).
- amy: `--icon URL` on `nsite/napplet publish`, surfaced in the publish output.
- card: renders the icon via Coil, monogram fallback when absent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 17:13:41 +00:00
Claude 4cd71b140c refactor(napplets): render browse rows via shared NoteCompose
Replace the bespoke NappletCard with NoteCompose — the same path the main feed
uses for kind 15129/35129 events. This reuses the author header, the shared
StaticWebsiteCard (title/description/source/servers/capability list + Open button
wired to NappletLauncher), and the standard reaction bar (reply/boost/like/zap),
instead of a second hand-rolled card that could drift from the feed and was
missing NoteCompose's timestamp, hidden-user handling, zap-amount menus, etc.

The new top bar + follow-list filter (defaultNappletsFollowList / matchAuthor)
are genuinely new and kept as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 16:47:05 +00:00
Claude 7aa5f10a72 feat(napplets): richer browse cards + follow-list filter bar
The Napplets browse screen now matches the other feed screens:

- Top bar gains a follow-list FeedFilterSpinner (drawer/back · filter · search +
  manage-permissions), persisted in account settings as defaultNappletsFollowList
  and applied via a new Account.liveNappletsFollowLists author-matcher so you can
  scope the list to a people set (All/Follows/custom).
- Each row is a rich card: author avatar + name, title, description, the declared
  capability chips, and the standard reaction bar (reply/boost/like/zap) wired to
  the canonical cache Note — so napplets get the same social actions as any event.

Follow-list plumbing mirrors the existing categories (AccountSettings field +
change fns, LocalPreferences persist/load, FollowListPrefs). LoggedInUserPictureDrawer
is now internal so the new NappletsTopBar can reuse the drawer opener.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 16:42:16 +00:00
Vitor PamplonaandGitHub 9dd2fb5ba9 Merge pull request #3334 from vitorpamplona/claude/modest-carson-lcgcsu
MoqLiteSession: send Ended announce on publisher close race
2026-06-22 12:25:14 -04:00
Claude 03aac0025d fix(nestsClient): write Ended announce when publisher.close() races registerAnnounceBidi
When publisher.close() acquires the gate lock before registerAnnounceBidi,
the announce bidi list is empty and no Ended message is sent. Fix by writing
Ended + finishing the bidi in registerAnnounceBidi when publisherClosed is
already true, mirroring what close() does for bidis it owns at close time.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015t3bsJruGerz53yafZyp9n
2026-06-22 15:56:26 +00:00
Claude d60a618653 feat(amy): list an author's napplets / nsites
Add `amy napplet list <author>` and `amy nsite list <author>`: fetch the
author's root + named manifests (15129/35129 for napplets, 15128/35128 for
nsites), keep the latest per identifier, and emit a summary of each (kind, d,
title, description, path count, servers, requires/aggregate, event id,
created_at). Thin assembly over ctx.drain + the quartz manifest accessors.

Harness README notes `amy napplet list` for enumerating what you've published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:38:21 +00:00
Claude 7bc95d836c feat(amy): nsite/napplet serve (local preview); drop standalone publish.sh
Add `amy nsite serve` and `amy napplet serve <author> [--d ID] [--port N]`:
fetch the manifest and serve its content over a local HTTP server, resolving
each request through quartz StaticSiteResolver (blob downloaded from Blossom
and sha256-verified per request, same as the device host) with SPA fallback to
index.html. Lets you open a published site/napplet in a browser to confirm it
loads and routes. (Static content only — a napplet's window.napplet.* runtime
still needs the Amethyst host; documented in the command + harness README.)

Implemented as thin cli glue (StaticSiteServe) over the resolver + commons
BlossomClient + the JDK HTTP server.

Also retire tools/napplet-test/publish.sh now that `amy napplet publish` is the
single source of truth; the harness README documents publish + serve via amy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:35:14 +00:00
Claude ece1f43975 feat(amy): publish napplets and nsites (ship a directory)
Add `amy nsite publish <dir>` and `amy napplet publish <dir>` so a static-site
or napplet directory can be shipped to Nostr in one command, building on the
new CLI/Blossom infrastructure.

- commons (jvmMain) StaticSitePublisher: the reusable "upload a tree" half —
  walks a directory (or single file), content-addresses each file, BUD-02
  signed-uploads it via BlossomClient, and maps it to an absolute web path
  (/index.html, /assets/app.js, …). Returns the NIP-5A path→sha256 tags.
- cli StaticSitePublish: thin shared flow — uploads via the commons publisher,
  hands the path tags to a kind-specific builder, signs with the account key,
  and broadcasts. nsite builds 15128/35128 (+ x aggregate); napplet builds
  15129/35129 (aggregate + requires already added by the quartz builder).
- nsite/napplet `publish` verbs wired into their routers.

Test harness README now recommends `amy napplet publish tools/napplet-test`,
keeping publish.sh as a no-amy fallback. Unit test covers the path mapping;
cli + commons build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:31:28 +00:00
Claude 08947510d9 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad 2026-06-22 15:04:44 +00:00
Claude b0332c9ff3 test(napplet): add on-device test harness (napplet + publish script)
A self-contained napplet (tools/napplet-test/index.html) that calls every
window.napplet.* API and renders each result on screen, for verifying the
NIP-5D host end to end on a real device — including the new
identity.getList/getZaps/getBadges, identity.onChanged, keys.onAction, and
resource.bytes nostr: paths.

publish.sh uploads it to a Blossom server (BUD-02) and publishes the NIP-5D
named-napplet event (kind 35129) via nak; README documents the flow and a
per-feature verification checklist. Tooling only — no app code or deps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 15:04:28 +00:00
Vitor PamplonaandGitHub 59361c378e Merge pull request #3333 from vitorpamplona/claude/loving-hopper-rn553t
Add comprehensive CLI command suite and Cashu wallet support
2026-06-22 11:03:43 -04:00
Claude 2c4fd48c96 fix(cli): realign amy nutzap relays with upstream NIP-65 inbox model
Upstream reworked kind:10019 to advertise the account's NIP-65 inbox (read)
relays as nutzap-receiving relays (was outbox). Realign amy:
`cashu wallet create` now defaults nutzapRelays to a new
Context.nip65ReadRelays() (kind:10002 read relays, falling back to outbox)
instead of outboxRelays(), so amy's kind:10019 matches the Android wallet's
again. The event's publish destination (anyRelays / sendLiterallyEverywhere)
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 14:58:51 +00:00
Claude a5eabbf1a2 Merge remote-tracking branch 'origin/main' into claude/loving-hopper-rn553t 2026-06-22 14:53:23 +00:00
Claude df15414424 Merge remote-tracking branch 'origin/main' into claude/awesome-pasteur-xwiwad
# Conflicts:
#	amethyst/src/main/res/values/strings.xml
2026-06-22 14:50:16 +00:00
Claude 188746485d docs(cli): correct nak parity tally (24 full / 3 partial / 7 missing)
Re-introspected the real nak binary: 34 functional commands. Fixes the
stale count — adds nsite to full (amy has NsiteCommands), corrects missing
to 7, and clarifies group/nip29 is an intentional MLS/Marmot divergence,
not a gap. Drops the stale "validate" cheap-win (key validate shipped).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 14:45:14 +00:00
ClaudeandVitor Pamplona cd14994859 fix(nutzaps): default kind:10019 relay tags to inbox list, drop dm
The advertised `relay` tags in our own kind:10019 now copy the NIP-65
inbox relay list only, rather than inbox + DM. Inbox relays are the
canonical "where others reach me" set, so they are the natural default
for "where to send me nutzaps". DM relays stay in the wallet's inbound
subscription as a safety net, but don't belong in the public kind:10019.

The publish destination of the kind:10019 event itself is unchanged — it
still broadcasts to our outbox via sendLiterallyEverywhere; only the relay
tags inside the event changed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
ClaudeandVitor Pamplona 33fdd34cf0 fix(nutzaps): advertise inbox+dm relays in kind:10019, not outbox
Complete the NIP-65 outbox-model alignment for NIP-61: the `relay` tags in
our kind:10019 are, per spec, the relays where the recipient *reads*
incoming token events — i.e. inbox-side relays others publish to. We were
advertising our outbox (write) relays there.

Both publish paths (initial wallet creation in CashuWalletViewModel and
P2PK key rotation in CashuWalletState.recreateNutzapKey) now advertise the
union of our NIP-65 inbox + DM relays. This mirrors the relay set the
wallet subscribes to for inbound kind:9321, so senders following our
kind:10019 publish exactly where we listen.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
ClaudeandVitor Pamplona 4e582d198d fix(nutzaps): receive nutzaps on inbox/dm/kind:10019 relays, not outbox
Inbound NIP-61 nutzaps (kind:9321) are messages other people send *to*
the user, so per the NIP-65 outbox model they must be read from the
user's inbox-side relays, not their outbox. The Cashu subscription used a
single relay set (outbox) for both the user's own NIP-60 events and
inbound nutzaps, so a sender following NIP-61 correctly (publishing to
the relays advertised in the recipient's kind:10019, or to the
recipient's NIP-65 inbox) could be missed.

Split the subscription relay sets per filter:
  - own NIP-60 wallet/token/history events keep reading from outbox,
    where the user published them (needed to restore on a fresh device);
  - inbound kind:9321 nutzaps now read from the union of the user's
    NIP-65 inbox + DM relays + the `relay` tags in the user's own
    kind:10019. The last one is NIP-61's source of truth for "where to
    send me nutzaps" and may be written by another client to a relay set
    unrelated to our NIP-65 lists, so we listen there too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHcZ2gv8ro9Q2bEiTSiHD8
2026-06-22 09:38:31 -04:00
vitorpamplonaandVitor Pamplona bd7c5c78cc chore: sync Crowdin translations and seed translator npub placeholders 2026-06-22 09:38:31 -04:00
ClaudeandVitor Pamplona 43565beb2b style: spotless formatting for CardFeedContentState 2026-06-22 09:38:31 -04:00
ClaudeandVitor Pamplona 2918dc5497 fix: replace LocalDate.ofInstant (API 34) with Instant.atZone chain (API 26+) 2026-06-22 09:38:31 -04:00
Claude 7a3c35c645 docs: document cashu, admin, serve, fetch code mode, key validate
Update the amy docs to reflect the new command surface:

- cli/README.md — add the Cashu (NIP-60/61), Relay management (NIP-86
  admin), and Run-a-relay (serve) sections; document fetch's nip19/nip05
  code mode and key validate.
- cli/DEVELOPMENT.md — add cashu.json to the on-disk layout and pin the
  command-family --json contracts (cashu keys/error codes + pointer to the
  cashu plan, admin {relay,method,result}, serve startup object).
- .claude/skills/amy-expert/SKILL.md — extend the "where things live" tree
  with AdminCommand/ServeCommand/cashu/, the commons/cashu + relayManagement
  shared modules, and the allowed :geode dependency.
- .claude/CLAUDE.md — note cli may depend on :geode (for serve), never on
  :amethyst/:desktopApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 02:14:11 +00:00
Claude e883d99f53 feat(napplet): implement keys.onAction (key binding + keys.action push)
Bind a napplet's registered keyboard/command actions to real hardware-key
combos and fire them back as keys.action pushes, so napplet.keys.onAction
actually triggers (previously registration was acked but never fired):

- protocol: RegisterAction / ActionRegistered carry the key combo (binding,
  from the SDK's action.defaultKey); the codec decodes defaultKey and echoes
  binding in the result; encodeKeysAction push envelope added.
- broker: registerAction returns the honored binding (still no key access for
  the applet; KEYS stays a declared-only, no-prompt capability).
- NappletKeyActions (host): a registry that parses combos like "Ctrl+Shift+S"
  / "Cmd+Enter" / "F2" and matches them against KeyEvents.
- NappletHostActivity: binds an action only after the broker authorizes it
  (from the keys.registerAction.result), unbinds on keys.unregisterAction, and
  overrides dispatchKeyEvent to turn a matching combo into a keys.action push
  via the shell bridge. Unmatched keys fall through to the WebView, so the
  applet's own text inputs keep working. Touch-only devices simply never match.

shim already passed the full action (incl. defaultKey) and wired onAction to
the keys.action push, so no shim change was needed. Conformance test now
covers the defaultKey decode + binding round-trip; all napplet suites green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:37:39 +00:00
Claude 1a1fb9ff32 feat(napplet): implement identity.onChanged push
Wire napplet.identity.onChanged end to end so an applet is notified when the
active user's public key changes (account switch / connect / disconnect):

- shim: onChanged registers a handler and opens a watch (identity.watch) on the
  first handler; closing the last one stops it (identity.unwatch). identity.changed
  pushes are dispatched to the handlers with the new pubkey.
- router: identity.watch (gated on the IDENTITY declaration) / identity.unwatch
  become WatchIdentity / UnwatchIdentity outcomes — a push subscription, like
  relay.subscribe, that never reaches the broker.
- NappletIdentityWatch (host): collects the active account's pubkey from the
  session manager and pushes identity.changed on each subsequent change (the
  current value is dropped — the applet already has it via getPublicKey). Torn
  down on unwatch and on service destroy.
- codec: encodeIdentityChanged push envelope.

Router unit tests cover watch (declared/undeclared) and unwatch; commons tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:32:07 +00:00
Claude 443c35762a feat(napplet): implement identity.getList/getZaps/getBadges + resource nostr:
Fill in the identity reads that previously fell through to Unsupported, and
add nostr: resolution to resource.bytes — both reading from what Amethyst
already has locally, matching the @napplet/nap shapes.

identity (AccountIdentityReader):
- getList(listType): public tag values (e/a/p/t/word/r/emoji) of the user's
  NIP-51 replaceable list of that type (bookmarks 10003, pins 10001, mute
  10000, interests 10015, communities 10004, channels 10005, emojis 10030).
- getZaps: ZapReceipt[] {eventId, sender, amount, content?} from kind-9735
  receipts p-tagging the user in the cache.
- getBadges: Badge[] {id, name?, description?, image?, thumbs?, awardedBy}
  from kind-8 awards p-tagging the user, resolved against their kind-30009
  definitions in the cache.

resource.bytes (NappletResourceFetcher):
- nostr: URIs (NIP-19) resolve to the referenced event's JSON
  (application/json). nembed carries it inline; note/nevent/naddr resolve
  from the cache then a bounded relay fetch; npub/nprofile resolve the
  author's kind-0. Still no direct network for the applet.

The wire codec already routed these (generic IdentityRead + identityResultField,
ResourceBytes), so no protocol change was needed. Commons napplet tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 01:27:53 +00:00
Claude 9a5e9090c0 feat(cli): amy key validate + fetch nip19/nip05 outbox resolution
- `key validate PUBKEY` — nak's `key validate`: parse an npub or 64-hex
  pubkey and report {valid, pubkey, npub}; never errors on bad input
  (reports valid:false) so scripts branch on the field.
- `fetch <nevent|naddr|nprofile|npub|note|nip05>` — code mode that resolves
  relays the way the Android app opens a shared link: the relay hints
  embedded in the nip19 code UNION the author's NIP-65 write (outbox)
  relays, draining the author's kind:10002 on a cache miss. This is nak's
  `fetch` (nip19-hint resolution); filter mode is unchanged.

Verified: key validate accepts fiatjaf's npub/hex and rejects garbage +
bad-checksum npubs; `fetch <npub>` drained the author's kind:10002,
queried their actual advertised write relays, and returned kind:0.

nak parity: 23 full / 3 partial / 6 missing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 01:06:01 +00:00
Claude 4453985f68 refactor(napplet): split the host Service and Activity by responsibility
The two largest Android napplet files mixed many concerns. Decompose each
into focused collaborators, behavior-preserving (both napplet test suites
stay green):

NappletBrokerService (641 -> 195 lines) is now a thin IPC shell — Messenger
transport, per-account broker cache, lifecycle — delegating to:
- gateways/AccountNappletGateways: the account -> NappletBroker adapter that
  wires all six gateway impls (relay publish/query, consent, wallet/NWC,
  resource, identity, upload).
- gateways/NappletResourceFetcher: data:/https:/blossom: resource fetching
  with the Tor-aware OkHttp client (sha256-verified blossom blobs).
- gateways/AccountIdentityReader: identity.* reads -> JSON (public data only).
- NappletConsentSummary: NappletRequest -> localized consent dialog text.
- NappletLiveSubscriptions: the live relay subscription registry (open/close/
  closeAll, single-EOSE latch, per-sub client for correct teardown).

NappletHostActivity (483 -> 336 lines) keeps the Activity lifecycle, WebView
hardening, and the shell<->broker bridge; the resource edge moves to:
- NappletContentServer: serves the shell + verified blobs (CSP headers, SPA
  fallback, shim injection) and owns the disk-cached blob HTTP client.

No wire or policy change; all decode/consent/exec still flows through the
shared NappletRequestRouter + NappletBroker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 00:45:00 +00:00
Claude 47cc76d9f1 feat(cli): amy admin (NIP-86) + serve (embed geode relay)
Two new nak-parity commands:

- `amy admin RELAY METHOD [args]` — NIP-86 Relay Management API over NIP-98
  HTTP auth. Full method set: ban/unban + allow/unallow pubkey, ban/allow
  event, allow/disallow kind, block/unblock IP, change name/description/icon,
  and all list-* queries. Reuses quartz's Nip86Client (request build + NIP-98
  auth + parse) and the Nip86Retriever HTTP path — extracted from amethyst to
  commons/jvmAndroid so amy and the Android relay-management screen share it.

- `amy serve [--host --port --path --db --admin]` — runs a Nostr relay by
  embedding geode (the standalone Ktor relay on quartz's relay-server code).
  In-memory by default (ephemeral, like nak serve); --db FILE for SQLite. The
  account's own pubkey is always an admin, so `amy admin` works against it out
  of the box. cli gains a :geode dependency (geode depends only on :quartz) and
  kotlinx-serialization-json (to render NIP-86 JSON results).

Verified end-to-end: `amy serve` + `amy admin ws://127.0.0.1:PORT
supported-methods|change-name|ban-pubkey|list-banned-pubkeys` round-trip
cleanly over real HTTP + NIP-98 against the live geode relay.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 00:19:29 +00:00
Claude 42a82c7e8b feat(cli): amy cashu receive/send/maintenance/mint-rec tiers
Complete the Cashu command surface, every verb a thin wrapper over the
shared commons CashuWalletOps the Android wallet runs:

  cashu receive ln SATS [--mint]      start mint, return bolt11 + kind:7374
  cashu receive complete QUOTE_ID     poll mint; on settle, mint proofs
  cashu receive resume QUOTE_ID       alias of complete
  cashu receive token TOKEN           redeem a cashuB token
  cashu receive nutzap-sweep [--mint] redeem inbound NIP-61 nutzaps
  cashu send ln INVOICE [--mint]      melt to a bolt11 (scrubs first)
  cashu send token SATS [--mint --memo]  export a cashuB token (scrubs first)
  cashu send nutzap USER SATS [--zapped --message]  P2PK-locked nutzap
  cashu maintenance scrub [--mint]    NUT-07 + NIP-09 prune spent proofs
  cashu maintenance restore MINT_URL  NUT-09 restore from seed
  cashu maintenance migrate-keysets [--mint]  consolidate onto active keyset
  cashu mint-rec show [--author] / add URL [--dtag --review] / remove ID

- scrubStaleProofs extracted into CashuWalletOps so Android's
  CashuWalletState.scrubLocallyStaleProofs and amy share one impl.
- Context.cashuRestore mirrors CashuWalletState.restoreFromMint (seed +
  NUT-13 counter bump); Context.cashuSeed warms the per-run seed.
- receive complete recovers the mint amount by decoding the quote's
  bolt11 (kind:7374 stores only the quote id), so it works statelessly.

Verified against mint.minibits.cash + live relays: receive ln returns a
real invoice, complete/resume poll a pending quote, mint-rec round-trips,
and every error path (insufficient_funds, no_mint, mint_quote_gone) is
clean. Happy-path mint/melt completions need a payable bolt11 (interop
harness, PR 9).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-22 00:10:40 +00:00
Claude b6f9630883 refactor(napplet): single-source the web contract and feed card in commons
Two shared extractions so the future desktop host reuses the exact same
sandbox and feed UI as Android, with no chance of drift:

Shared web contract:
- Move shell.html + shim.js into commons composeResources
  (files/napplet/), read via Res.readBytes on any platform.
- New NappletWebContract (commons/commonMain) single-sources the whole
  web contract: the shell/shim loaders plus the internal origin/host/URLs
  and both Content-Security-Policies (SHELL_CSP, APP_CSP). The Android
  host preloads the bytes in onCreate and reads every origin/CSP constant
  from NappletWebContract instead of its own duplicated constants and
  assets.open() calls.

Shared feed card:
- New StaticWebsiteCard (commons/.../ui/note) renders the inert NIP-5A /
  NIP-5D preview card: self-contained with commons compose-resource
  strings, LocalUriHandler for links, and inlined card chrome. It takes
  an isNapplet flag and an onOpen launch slot, so it never executes applet
  code itself.
- amethyst's note/types/StaticWebsite.kt becomes thin event->card
  adapters that supply the sandboxed onOpen launch.

Card strings move to commons strings.xml. Both napplet test suites
(commons jvmTest + amethyst) stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-22 00:10:22 +00:00
Claude 0cff3bf8e2 refactor(napplet): extract host-agnostic NappletRequestRouter to commons
Move the decode → broker → encode orchestration out of Android's
NappletBrokerService.handleMessage and into a pure, transport-free
NappletRequestRouter in commons/jvmAndroid. It returns a small Outcome
(Ignore / Reply / OpenSubscription / CloseSubscription / Push) that each
host acts on, so the Android service and the future desktop host share
the routing brain and can't drift on wire behavior.

The service now resolves the broker and dispatches on the Outcome,
supplying only the Messenger transport and the live relay subscription.
openLiveSubscription takes the decoded filters from the router instead of
re-decoding the payload, and the now-redundant process() is removed.

Unit-tested in commons/jvmTest (NappletRequestRouterTest).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 23:59:53 +00:00
Claude 72a7f59d14 fix(cli): align amy cashu publish + nutzap relays with Amethyst
Two behavioral divergences from the Android wallet, found while auditing
for parity:

- Publish targets: Amethyst sends every cashu event via
  sendLiterallyEverywhere (all the user's relays). amy published only to
  outboxRelays(); switch to anyRelays() (outbox + inbox + keypackage), the
  CLI's closest analog, so the wallet lands on the same broad relay set.
- Nutzap relays on create: Amethyst always advertises the account's outbox
  relays in kind:10019 (so senders publish nutzaps where the user reads).
  amy defaulted to none; default to outboxRelays() unless --relay overrides.

Also align cashuSnapshot()'s store query with commons'
CashuWalletFilterAssembler exactly (six authored kinds by authors=[pk],
inbound nutzaps by #p) so amy projects the same event set the app
subscribes to. Verified the emitted kind:10019 now carries relay/mint/
pubkey tags identical to NutzapInfoEvent.build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 23:54:48 +00:00
Vitor PamplonaandClaude Opus 4.8 a971f4389e perf(notifications): cut per-card composition cost on the notifications feed
The notifications feed is dominated by MultiSetCards, each rendering a gallery
of up to 30 author avatars. Profiling a loaded account showed the per-author
work on the main thread, not the GPU, as the scroll-jitter driver.

Three feature-preserving cuts:

- Hoist account-global reads out of the per-author avatar. The auto-play-gif
  setting and the logged-in follow set were collected once *per author* (~60
  redundant Flow collectors / coroutine launches per card). They are now
  collected once per gallery and passed down via LocalAuthorGalleryRenderContext.

- Dedupe the per-author metadata subscription. Each avatar fired
  UserFinderFilterAssemblerSubscription twice (via observeUserPicture +
  observeUserContactCardsScore); both observers gained a `subscribe` flag so the
  gallery subscribes once per author.

- Replace the per-event DateTimeFormatter day-bucketing in convertToCard with a
  LocalDate.toEpochDay() Long key (identical grouping, no Instant/ZonedDateTime/
  String allocation per reaction/zap/repost), and hoist the ZoneId lookup.

Measured before/after on a Samsung SM-T220 (loaded account, identical scripted
scroll, dumpsys gfxinfo): Slow-UI-thread events ~13 -> ~5 (~60% fewer),
95th-pct frame ~52ms -> ~38ms, janky frames ~13% -> ~10%. GPU timings unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:36:59 -04:00
Claude 8bb537438f feat(cli): amy cashu wallet/mint/balance on shared NIP-60/61 code
Add the offline Cashu command tier to amy, all driven by the shared
commons wallet code so amy exercises the same path as the Android app:

  amy cashu wallet create [--mint URL] [--mints a,b] [--privkey HEX] [--relay r1,r2]
  amy cashu wallet show
  amy cashu wallet export-key
  amy cashu wallet destroy
  amy cashu mint ping URL          (stateless)
  amy cashu mint info URL          (stateless)
  amy cashu balance [--mint URL]

- create/destroy reuse commons CashuWalletOps.publishWalletEvents /
  deleteWallet; show/balance reuse the CashuWalletReader projection over
  the local event store; mint ping/info hit quartz's MintHttpClient.
- Context gains cashuOps() (wired to the file NUT-13 counter store + a
  per-run seed cache) and cashuSnapshot(); DataDir gains cashu.json.
- Extraction D: CashuKeysetCounterStore contract in commons +
  FileCashuKeysetCounterStore (atomic ~/.amy/<account>/cashu.json).

PRs 4 of cli/plans/2026-05-28-cashu-cli.md (extractions A–D + offline
tier). receive/send/maintenance/mint-rec + interop harness still pending.
Verified end-to-end against mint.minibits.cash and live relays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:30:59 +00:00
Claude d111c2589d refactor(commons): extract CashuWalletReader projection for amy reuse
Add a pure, stateless CashuWalletReader in commons that projects a stream
of NIP-60/61/87 events into a WalletSnapshot (wallet/nutzap-info events,
decrypted mints, unspent token entries, history, pending quotes, nutzaps,
recommendations, plus balance + per-mint balances).

Android's CashuWalletState keeps its incremental dirty-tracking and
StateFlow plumbing but now delegates the two tricky computations —
del-rollover over decrypted tokens (computeUnspent) and the
destroyed/expired pending-quote filter (computePending) — to the shared
reader instead of carrying its own copies. amy will call project() once
per command over its event store.

Extraction C of cli/plans/2026-05-28-cashu-cli.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:20:25 +00:00
Claude fc7db088b2 refactor(commons): extract CashuWalletOps to commons for amy reuse
Move the NIP-60/61 wallet orchestration layer (CashuWalletOps + its
result types: TokenEntry, MintQuoteStarted, MintCompleted, MeltCompleted,
SendTokenCompleted, RedeemCompleted, NutzapSent, RestoreOutcome,
MigrationResult, CreatedWallet, describeMintError) out of amethyst into
commons/jvmAndroid/cashu/ops.

The class already had zero Android dependencies — it takes signer,
publish, okHttpClient, secretFactory, and the NUT-13 counter callbacks as
constructor params. It lands in the jvmAndroid source set (not commonMain)
because it composes quartz's jvmAndroid CashuMintOperations/MintHttpClient
and uses ConcurrentHashMap. Both Android (amethyst) and the JVM CLI (amy)
can now drive the exact same wallet code path.

This is Extraction B of cli/plans/2026-05-28-cashu-cli.md. Android
callers (CashuWalletState, the wallet ViewModels, AccountViewModel)
updated to the new package; no behavioral change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 22:17:04 +00:00
Claude 0b76518ef2 refactor(napplet): move wire codec to commons for desktop reuse + desktop host plan
Set the desktopApp up to host napplets/nsites by maximizing the shared
core and documenting the edge it must build.

- Moved NappletProtocolJson (the wire codec) from amethyst to
  commons/jvmAndroid (package ...commons.napplet.protocol), next to the
  NappletRequest/Response types it marshals. It depends only on quartz +
  kotlinx.serialization + java.util.Base64 (Android 26+/JVM), so a future
  desktop host marshals through the identical object — request/result/push
  shapes can't drift between platforms. amethyst host/service/tests updated
  to import it; tests stay in amethyst and still exercise it.
- Added desktopApp/plans/2026-06-21-napplet-desktop-host.md: what's already
  shared (broker, protocol, codec, resolver, the shell.html/shim.js web
  contract), what desktop must build (KCEF/JCEF engine, custom-scheme
  serving, isolation, transport, gateways, UI), the decisions to make, a
  security-parity checklist, and recommended further extractions
  (NappletRequestRouter, shared web assets, the inert feed card).

commons:jvmTest and the amethyst napplet suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 22:12:28 +00:00
Claude dfc237d27b refactor(napplet): audit fixes — sub lifecycle, broker/http caching, shim asset
Code-audit pass over the napplet subsystem (see
plans/2026-06-21-napplet-code-audit.md):

Correctness:
- Live subscriptions now store the exact INostrClient that opened them, so
  teardown unsubscribes from the right account even after an account
  switch (previously leaked on the original account).
- Multi-relay subscriptions emit a single relay.eose via an eoseSent
  latch, instead of one per relay (the SDK expects one).

Performance:
- The broker is cached per account (reference identity) instead of rebuilt
  on every request.
- The blob OkHttpClient is cached and reused (keyed by Tor port) for
  connection pooling, instead of a new client per fetch.

Refactor / docs:
- Moved the 105-line injected shim from a Kotlin string constant to
  assets/napplet/shim.js (loaded once like shell.html); corrected stale
  onChanged / subscribe comments.

Deferred (documented with rationale): cross-relay event dedup,
background pause/resume of live subs, request ordering, and the
recommended NappletProtocolJson -> commons/jvmAndroid move.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 22:01:57 +00:00
Claude cd2bae081e feat(napplet): live subscription tail, multi-filters, resource.cancel
Implements the remaining tractable conformance follow-ups:

- Live subscription tail: relay.subscribe now opens a real
  client.subscribe whose SubscriptionListener streams relay.event
  (stored + live), relay.eose, and relay.closed pushes keyed by subId,
  instead of a one-shot snapshot. relay.close unsubscribes (tracked in
  liveSubs, torn down in onDestroy). The broker only authorizes the
  subscription (RELAY consent) and returns Subscribed; the host owns the
  live stream. Shim dispatches relay.closed too.
- Multi-filters: relay.query/subscribe honor every filter in filters[],
  not just the first (decodeFilterList; gateway query(List<Filter>);
  queryEvents unions across filters; max limit applied).
- resource.cancel: accepted at the host edge as a no-op Done.

Conformance tests extended (multi-filter decode, relay.closed push);
commons:jvmTest and the amethyst napplet suite pass.

Still open: identity getList/getZaps/getBadges + onChanged (shapes
underspecified), the keys.action push (needs a host trigger UI), the
resource nostr: scheme, inc + the niche domains, and on-device
verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 21:43:36 +00:00
Claude 3df9f831b8 feat(quartz): complete KindNames registry for every supported kind
Fill the KindNames registry from 145 to 280 entries so every event kind
quartz defines a class for has a canonical English label + NIP. Adds the
whole NIP-90 DVM request/response set, NIP-29 relay groups, NIP-60/61
Cashu wallet + nutzaps, NIP-43 relay members, WebRTC calls (NIP-AC),
marketplace (NIP-15), git PRs/state (NIP-34), CLINK, NIP-51 curation
sets, NIP-85 assertions, Marmot MLS events, and more.

For the handful of kind numbers shared by multiple classes, the registry
keeps one canonical entry (e.g. CashuToken over the deprecated nip61
TokenEvent, ExternalIdentities over GalleryList, ReleaseArtifactSet over
SoftwareRelease). Kind 1 stays "Notes" rather than the bounty value-add
helper that reuses it.

Also point `amy nip`'s Nostr fallback at quartz's canonical NipText kind
30817 (NipTextEvent) alongside the wiki and long-form kinds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:41:52 +00:00
Claude 938f40d568 refactor(amethyst): fall back to quartz KindNames for unknown kinds
The relay kind chips, NIP-86 management screen, and app-recommendation
labels all map kinds to translated R.string labels via kindDisplayName(),
returning -1 for kinds without a localized name. Route that -1 case
through the shared quartz KindNames registry so any kind known to the
protocol layer renders its canonical English label instead of a bare
"k<kind>"/empty placeholder. Translations still win where they exist;
quartz is the canonical source for the rest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:10:31 +00:00
Claude 0f23051d54 feat(napplet): implement the four SDK conformance breakers
Closes the 🔴 items from the conformance audit so stock @napplet/shim
napplets interop:

1. Shell handshake: the host answers shell.ready with shell.init
   {capabilities:{domains,protocols},services} built from the declared
   domains (NappletProtocolJson.encodeShellInit), so supports() works.
2. Id-less messages: onShellMessage no longer drops messages without an
   id — shell.ready is answered locally and fire-and-forget messages get
   a synthetic id so they reach the broker.
3. keys: keys.registerAction/unregisterAction decode and the broker acks
   them (declared-gated, no consent) so registerAction() resolves; the
   shim dispatches the keys.action push. (Global-key binding is a
   follow-up — keys.action isn't emitted yet.)
4. upload: realigned to upload.upload{request:{data,mimeType,filename}} →
   rich UploadResult{ok,uploadId,status,url,sha256,size,mimeType};
   shell.html inlines the request Blob as base64 so it survives the
   bridge; the gateway uploads via the app's BlossomUploader to the
   user's kind:10063 server with a signed auth event.

NappletSdkConformanceTest's gap guards flip to conformance assertions for
shell.init/keys/upload; inc stays the one documented gap. commons:jvmTest
and the amethyst napplet suite pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 21:02:55 +00:00
Claude b6c065d421 feat(quartz,cli): add KindNames registry + amy kind
Introduce a canonical, i18n-free event-kind registry in quartz:
`com.vitorpamplona.quartz.kinds.KindNames` maps each of the 145 known
kinds to an English label + the defining NIP. The data is ported from
Amethyst's relay-view `kindDisplayName` mapping (the NIP derived from
each event class's package), so the "what is kind N" knowledge now lives
once in quartz instead of only in the Android UI.

`amy kind <N|NAME>` looks a kind up by number (label + NIP) or searches
labels by name — a thin wrapper over KindNames, dispatched statelessly
(no account/network).

i18n split: quartz holds the canonical English (quartz is intentionally
translation-free); localized front ends overlay their own strings and can
fall back to KindNames.nameFor() for kinds they don't translate. amy
prints the English directly.

Verified: kind 1/0/30023/1059/24133 labels+NIPs, name search ("podcast"
→ 4), unknown → known:false, text + JSON output.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 21:00:54 +00:00
Claude 749a301469 feat(cli): cheap nak-parity wins — key nip49, nip lookup, blossom check/mirror
- `amy key encrypt|decrypt` (NIP-49): encrypt a secret key to ncryptsec1…
  and back. Bidirectionally interop-verified vs the real nak binary
  (amy-encrypt → nak-decrypt and nak-encrypt → amy-decrypt both match).
- `amy nip N` / `amy nip list`: look up a NIP — the nostr-protocol/nips
  git repo FIRST, then a Nostr fallback (NIP-50 search over wiki kind:30818
  + long-form kind:30023 on search-capable relays). Slug normalization
  handles 1→01 and hex-suffixed NIPs (7d→7D, 5a→5A); titles parsed from the
  setext headings NIP docs use.
- `amy blossom check|mirror`: HEAD-check blobs (exit 1 if any missing, like
  nak) and request BUD-04 mirroring of a blob from a source URL.

Also persists the full introspected nak comparison into ROADMAP. `kind`
stays deferred — it needs a kind→schema registry that doesn't exist in
quartz (would be data/logic that doesn't belong in cli).

Verified: key round-trip + nak cross-impl both ways; nip repo titles for
01/46/5A/7D + Nostr fallback on miss; blossom check 200/404 + exit codes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:50:02 +00:00
Vitor PamplonaandGitHub 2187158c53 Merge pull request #3327 from vitorpamplona/claude/disappearing-scaffold-animation-zk9lql
Fix disappearing bar settle logic to prevent blank bands on partial reveals
2026-06-21 16:48:28 -04:00
Claude d96d0220fb feat: gap-safe settle, snappier spring, and jitter dead-zone for disappearing bars
Three refinements to the disappearing top/bottom bar animations:

- Gap-safe settle: settleToNearestEdge could snap a partially-collapsed bar to
  fully hidden whenever it was past the halfway point, even when the content had
  only scrolled part of a bar height (e.g. a gentle flick from the top). Because
  the content padding is fixed and the bar is translated, hiding it further than
  the content scrolled reopens the same blank band the reveal-damping fix removed.
  Each bar now latches whether it has actually reached its hidden edge through
  scrolling; the settle only commits to fully hidden when that latch is set,
  otherwise it settles back into view. This keeps the deliberate-reveal behavior
  (a sub-halfway reveal after fully hiding still snaps back hidden) without the gap.

- Snappier settle spring: StiffnessMediumLow -> StiffnessMedium so the bars
  resolve to their edge with a quick native snap instead of a slow float. Still
  DampingRatioNoBouncy, and overshoot stays clamped by animateOne's bounds.

- Micro-scroll dead-zone: ignore sub-pixel scroll attempts so jitter doesn't
  nudge the bars or flip the binary status-bar toggle. Kept tiny and symmetric so
  the reveal never lags the content enough to open a gap.

Proportional top/bottom collapse was intentionally left out: both bars already
move at the same pixel rate (visual lock-step for their shared travel), and
forcing the shorter bar to finish at the same time as the taller one would push
it off the 1:1 content track and reintroduce a gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
2026-06-21 20:32:03 +00:00
Claude ed3fbdd25e test(napplet): SDK wire-conformance audit + pinned conformance suite
Feature-by-feature audit of our edge layer against the canonical
@napplet/nap@0.15.0 / @napplet/core@0.15.0 message types, plus a test
suite that pins the codec to the SDK's exact wire so drift fails CI.

Audit (plans/2026-06-21-napplet-sdk-conformance-audit.md) catalogs every
domain with the verified wire shapes and ranks the inconsistencies found:

- 🔴 shell handshake missing: SDK uses shell.ready -> shell.init{capabilities,
  services} and answers supports() locally; we model a shell.supports request
  the SDK never sends, so a real napplet's capability env stays empty.
- 🔴 host drops id-less messages (shell.ready / inc.emit / keys.unregisterAction).
- 🔴 keys.* rejects at the boundary (only client-side stubs).
- 🔴 upload non-conformant (upload vs upload.upload; flat base64 vs
  request:{data:Blob}; and a Blob can't cross our JSON string bridge).
- ◐ relay query/subscribe use only the first of filters[]; identity
  getList/getZaps/getBadges + onChanged; resource nostr: + cancel;
  relay.closed + live tail.

Conformant and now pinned by NappletSdkConformanceTest: relay
publish/publishEncrypted/query/subscribe + event/eose pushes, identity
reads (method-specific result fields), storage get/set/remove/keys,
resource.bytes, and the error convention. Gap guards assert the current
(non-conformant) behavior so each flips intentionally when fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 20:25:20 +00:00
Claude 2c37ae642c feat(nip46): handle auth_url challenges in the remote-signer client
Adds NIP-46 `auth_url` (web-authorization) support to quartz's remote
signer, so amy (and Amethyst) can use auth-requiring bunkers like
nsec.app / nsecbunker:

- Both BunkerResponse deserializers (kotlinx + the JVM/Android Jackson
  one actually used by OptimizedJsonMapper) now special-case
  `{result:"auth_url", error:<url>}` BEFORE the generic error branch,
  which previously flattened it to a plain error and dropped the marker.
- RemoteSignerManager gained an `onAuthUrl` callback and a wait loop: on
  an auth_url response it surfaces the URL once and keeps waiting for the
  real response under the same request id (UNLIMITED channel so both are
  buffered). newResponse peeks the pending entry (get, not remove) so the
  follow-up isn't dropped; the waiter still removes it in finally.
- NostrSignerRemote forwards `onAuthUrl`; amy's Context prints the URL to
  stderr and the pending command keeps waiting.

Tests: a deserializer test (auth_url keeps result+error) and a manager
test (auth_url surfaced via callback, then the real response resumes the
request) — both green; existing duplicate/late-response manager tests
still pass after the get-vs-remove change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:19:01 +00:00
Claude 0d2ac83ebb feat(nsite): runtime hardening — blossom: fetch, sniffing, server fallback, blob cache
Closes the remaining nsite/napplet runtime gaps, all keeping content
integrity (every blob is sha256-verified) and the sandbox intact:

- resource.bytes blossom: scheme — blossom:<sha256> fetches from the
  user's kind:10063 Blossom servers and verifies the hash before
  returning. nostr: stays deferred (bytes semantics unspecified).
- Content-type byte-sniffing in the resolver: when a manifest path has
  no/unknown extension, sniff magic bytes (png/jpeg/webp/gif/pdf/wasm/...).
  Text/markup is never sniffed so HTML detection stays extension-driven.
  Unit-tested in quartz.
- kind:10063 server fallback: the launcher augments the manifest's servers
  with the author's published Blossom list (best-effort, when cached).
- Blob caching: the host OkHttp client caches blobs on disk with a forced
  immutable policy (content-addressed). The resolver re-verifies every
  served blob's sha256, so a stale/poisoned cache entry can't be served.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 20:11:15 +00:00
Claude 8b7caa5b01 feat(cli): NIP-46 nostrconnect:// reverse flow (both sides)
Add the client-initiated NostrConnect flow to complete NIP-46 parity:

- `amy login --nostrconnect [--relay …] [--name N]` (client) mints a
  transport keypair, prints a nostrconnect:// offer, subscribes, and
  waits for the signer's connect ACK (a kind:24133 whose decrypted
  result echoes our secret). The ACK's author is the signer; it persists
  a bunker account acting as that key.
- `amy bunker connect nostrconnect://…` (signer) parses a client offer,
  sends the secret-echo ACK, then services that client's requests on the
  offer's relays. Shares the serve loop with `amy bunker`.

NostrConnect.kt holds the offer parse/build (+percent-decode) and the
client handshake. BunkerCommand grew a `connect` sub-mode and factored
the request loop into serve().

Verified end-to-end:
- amy client ⇄ real `nak bunker connect`: amy learns nak's key and signs;
  event authored by nak, signature valid.
- amy client ⇄ amy `bunker connect`: same, authored by the host key.

Only `auth_url` challenges remain unimplemented in the NIP-46 surface.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 20:04:03 +00:00
Claude 695a5b4b25 fix(cli): nak-compatible bunker — percent-encoded URIs + get_relays
Close the NIP-46 interop gap against the real nak binary:

- `Identity.fromBunkerUri` now URL-decodes the relay/secret params. nak
  emits `bunker://<pk>?relay=wss%3A%2F%2F…&secret=…`; without decoding,
  `amy login` of a nak bunker URI produced a broken relay and never
  connected. (This was the one real break — found by testing vs nak.)
- `amy bunker` now percent-encodes the relay/secret in the URI it prints,
  matching nak's output format.
- `amy bunker` implements `get_relays` (returns its relay set), so nak
  clients that probe it get a proper reply instead of an error.

Verified end-to-end with `go install`-built nak over relay.damus.io,
both directions:
- `amy login bunker://` ⇄ `nak bunker`: amy signs, event authored by
  nak's key, signature valid.
- `nak event --sec bunker://<amy>` ⇄ `amy bunker`: nak signs through
  amy, event authored by amy's key; amy logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:52:37 +00:00
Claude 54a6b44324 feat(cli): NIP-46 bunker — remote signer server + bunker login
Add both halves of NIP-46 remote signing so two amy processes interop:

- `amy bunker [--relay …] [--secret S] [--timeout SECS]` runs a remote
  signer for the active local-key account: prints a bunker:// URI, then
  subscribes to kind:24133, decrypts each BunkerRequest, dispatches to
  ctx.signer (connect/get_public_key/sign_event/nip04/nip44/ping), and
  publishes the encrypted BunkerResponse. Long-running like `subscribe`.
- `amy login bunker://PUBKEY?relay=…&secret=…` creates a remote-signer
  account: mints a local transport keypair, records the connection, and
  acts as PUBKEY. Context builds a NostrSignerRemote (vs the local
  NostrSignerInternal) and runs openSubscription()+connect() in prepare();
  every signing/encryption call is delegated to the bunker.

Storage: IdentityFile gains a `bunker` block; the transport key is kept
in the existing SecretStore `secret` field. Identity/DataDir load+save
handle the remote-signer account type; `canSign` reflects "writeable via
bunker".

Thin assembly over quartz nip46RemoteSigner (NostrSignerRemote,
BunkerRequest*/BunkerResponse*, NostrConnectEvent). Verified end-to-end
over relay.damus.io: alice hosts a bunker, bob logs in and `amy event`
signs remotely — the note is authored by alice's key and verifies
(id_ok + signature_ok); bunker logs connect→ok, sign_event→ok.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:38:56 +00:00
Claude 73d52401cd fix: remove reveal damping so the top bar stays glued to content
The 0.5 reveal-sensitivity damping in DisappearingBarNestedScroll made the
bars reveal at half the rate they hide. Because the content scrolls 1:1, the
bar offset would lag behind the content scroll: after hiding the chrome and
scrolling back up to the top, the list reaches its top while the bar is still
only half revealed, leaving a blank band between the bar and the first item.

The bar's translationY must mirror the content scroll offset exactly so its
bottom edge stays glued to the first item's top edge. Any persistent reveal
damping breaks that invariant and opens the gap, so revealing now tracks the
finger 1:1 like hiding does.

The original goal of the damping — keeping the chrome from popping back on the
tiny reverse drag a finger makes when it catches a fast scroll — is still met
without breaking the 1:1 invariant: a partial reveal that doesn't cross the
halfway point is snapped back to the hidden edge by settleToNearestEdge on
fling/lift, and the binary OS status bar is already debounced by the
show/hide hysteresis in the scaffold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011jbLnoWks19ottXNrMZ6DH
2026-06-21 19:35:30 +00:00
Claude 76d9951d4f feat(napplet): SPA route fallback + external-link handoff in the host
Two nsite/napplet runtime improvements in the sandboxed WebView host,
both keeping the trust boundary intact:

- SPA fallback: a document navigation (Accept: text/html) to a route not
  in the manifest now serves the verified index.html instead of 404, so
  client-side-routed static sites survive deep links and refreshes.
  Missing sub-resources (js/css/images) still 404 — they don't accept
  html — so a broken asset never silently returns the page. The fallback
  only ever serves the manifest's own hash-verified index.html.

- External links: a link the user taps to an off-origin host is handed to
  the system browser via ACTION_VIEW instead of silently failing. Gated on
  a real user gesture (so a hostile site can't auto-redirect to spam-open
  the browser) and restricted to http/https (no arbitrary intent schemes).
  The sandbox WebView itself never navigates away from the internal origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 19:29:43 +00:00
Claude 69d03bacca feat(cli): add nak-style git (NIP-34) + podcast (NIP-F4) commands
git (repository metadata + collaboration events; clone/push packfile
transport is out of scope):
- git announce  publish a kind:30617 repository announcement
- git list      list a user's repo announcements
- git show      print one announcement (naddr or kind:pubkey:id), cache-first
- git issue     publish a kind:1621 issue against a repo (fetches the repo
                announcement to build the EventHintBundle)

podcast (NIP-F4):
- podcast metadata  publish kind:10154 show metadata (replaceable)
- podcast publish   publish a kind:54 episode (--audio URL[,URL])
- podcast list      list a user's metadata + episodes

Thin assembly over quartz GitRepositoryEvent / GitIssueEvent /
PodcastMetadataEvent / PodcastEpisodeEvent. Verified offline: announce->
show cache round-trip (name/clone/hashtags), issue kind:1621 against the
repo, podcast metadata kind:10154 + episode kind:54.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:13:21 +00:00
Claude fb35c85de4 feat(cli): add nak-style sync (NIP-77 Negentropy) primitive
`amy sync --relay URL [filter] [--down] [--up]` reconciles the local
event store with a relay using NIP-77 Negentropy:

- negotiate the symmetric difference under the filter (drives quartz
  NegentropySession over a raw WebSocket, mirroring geode's interop
  driver),
- --down (default) downloads the ids the relay has and we lack via
  Context.drain,
- --up uploads the events we have and the relay lacks via
  Context.publish.

Filter flags match fetch/subscribe; an empty filter reconciles the
whole store. Verified against relay.damus.io: cold store -> need=60,
downloaded=60; immediate re-sync -> need=0 (idempotent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 19:09:18 +00:00
Claude b3b23df675 Merge origin/main into claude/loving-hopper-rn553t
Integrate the `refactor(cli): simplify amy dispatch, Context lifecycle,
and store handling` change (Commands.kt removed, new route() sub-verb
helper, Context.open(dataDir).use { } lifecycle) with the nak-parity
Tier-1 commands added on this branch.

Re-wiring:
- Dropped Commands.kt; wired the 16 new verbs directly into Main.kt's
  dispatch (stateless: decode/encode/verify/key/filter + relay info
  interception; account-path: event/publish/fetch/subscribe/count/
  encrypt/decrypt/gift/outbox/blossom).
- Converted every new command from hand-rolled try/finally to
  Context.open(dataDir).use { ctx -> } and the multi-verb dispatchers
  (gift/blossom/key) to the shared route() helper.
- RelayCommands.dispatch now routes via route(); `relay info` stays a
  stateless Main interception and is also in the route map.

Verified post-merge: build green + smoke test across stateless,
account-path, and route() sub-verb paths (decode/key/filter, event->
verify round-trip, gift/blossom bad-verb errors, relay info on damus).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 18:47:18 +00:00
Vitor PamplonaandGitHub abd540e3c2 Merge pull request #3326 from vitorpamplona/claude/relaxed-meitner-3yr85y
Refactor command dispatch: extract Router helper, remove Commands.kt
2026-06-21 14:39:03 -04:00
Vitor PamplonaandGitHub 1909f402ab Merge pull request #3325 from vitorpamplona/claude/feed-media-prefetch
feat(feeds): prefetch media + pre-parse text ahead of the viewport
2026-06-21 14:36:50 -04:00
Claude 8e1059a4ab feat(cli): add nak-style blossom blob commands (upload/download/list/delete)
Sixth batch of nak parity — Blossom (NIP-B7 / BUD-01/02/04):

- `amy blossom upload --server URL FILE [--mime-type M]` — authed upload,
  prints the blob URL + sha256.
- `amy blossom download URL|HASH [--out FILE]` — public download (accepts
  a full URL or a HASH + --server).
- `amy blossom list --server URL [USER]` — list a user's blobs (authed).
- `amy blossom delete HASH --server URL` — delete a blob you own.

Reuses commons BlossomClient + BlossomAuth and quartz
BlossomAuthorizationEvent / sha256; list/delete use OkHttp directly with
the quartz-built kind:24242 auth header. Verified a full upload->download
sha256 round-trip against blossom.primal.net and an authed list.

This completes the Tier-1 nak-parity surface (decode/encode/verify/key/
event/publish/fetch/subscribe/count/encrypt/decrypt/gift/filter/relay
info/outbox/blossom); only the kind/nip reference lookups remain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:46:27 +00:00
Vitor PamplonaandClaude Opus 4.8 df1982a7f6 feat(feeds): prefetch media + pre-parse text ahead of the viewport
Warms the next/previous few feed notes off the main thread so media and
link previews are ready before the user scrolls to them, and pre-parses
rich-text bodies into the shared cache so scroll-time composition is a
cache hit instead of a UI-thread parse.

Per upcoming note (off Dispatchers.Default, deduped via a per-feed set,
cancelled on a new visible range):
- Pre-parses TextNote/Comment bodies through CachedRichTextParser using the
  renderer's exact key, so the composition reads a cached parse. Other kinds
  still get their media/links discovered, just without the render-cache warm.
- Prefetches images into Coil — inline content images, NIP-92 imeta blobs,
  NIP-94 file-header url tags, and video poster frames — and records each
  decoded aspect ratio in MediaAspectRatioCache so the box is reserved on
  first layout (no jump). Video poster ratios seed the video URL's box too.
- Warms OpenGraph/link previews via UrlCachedPreviewer.
Gated on showImages()/showUrlPreview() so it honors data-saver/Wi-Fi-only.
Video bytes are deliberately not prefetched (large, HLS, player pool already
starts fast).

Wired centrally at the two feed dispatchers (RenderFeedContentState,
RenderFeedState) so every list feed routed through them is covered without
per-screen wiring, plus direct hooks for the custom-render feeds (hashtag,
profile notes) and a LazyGridState variant for the grid feeds (gallery,
products, discover).

CachedRichTextParser is now content-addressed (memoized contentHash on
ImmutableListOfLists) so an off-thread pre-parse maps to the same entry the
renderer looks up, and its cache grows 50 -> 500 to hold the prefetch +
multi-feed working set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:44:39 -04:00
Claude 0fb1ca473e fix(profile): label the Apps tab "Apps & Sites" (it lists nsites too)
The tab already lists both NIP-5D napplets and NIP-5A nsites (the filter
and subscription include 15128/35128); rename the label so nsites aren't
hidden behind an "Apps"-only name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 17:44:22 +00:00
Claude 62d0b16740 feat(cli): add nak-style filter, outbox, relay info primitives
Fifth batch of nak parity:

- `amy filter [filter flags]` — stateless: assemble + print a NIP-01
  filter JSON from the same flags fetch/subscribe use (no query sent).
- `amy outbox USER` — show a user's NIP-65 read/write relays (outbox
  model), cache-first with a relay-drain fallback / --refresh.
- `amy relay info URL` — stateless NIP-11 info-document fetch over HTTP
  (Accept: application/nostr+json), parsed by quartz
  Nip11RelayInformation.

filter + relay info dispatch before account resolution (no ~/.amy
needed). Verified against relay.damus.io (NIP-11 doc) and fiatjaf's
kind:10002 (outbox read/write sets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:42:28 +00:00
Claude 0ec3fc69e8 feat(cli): add nak-style count, encrypt/decrypt, gift primitives
Fourth batch of nak parity:

- `amy count [filter]` — NIP-45 COUNT, per-relay match counts (reuses
  quartz INostrClient.count). Reports per-relay + max as `total`.
- `amy encrypt --to USER [TEXT]` / `amy decrypt --from USER [CIPHER]` —
  raw NIP-44 (default) or NIP-04 (--nip04) with the active account key.
- `amy gift wrap --to USER [EVENT]` / `amy gift unwrap [WRAP]` — NIP-59
  seal+wrap and unwrap+unseal (reuses SealedRumorEvent/GiftWrapEvent).

Text/ciphertext/JSON all read from arg or stdin. Verified with two
local accounts: NIP-44 + NIP-04 round-trips, gift wrap(1059)->unwrap
recovering the inner note + author, and count=60 against relay.damus.io.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:38:01 +00:00
Claude afc3c83baa feat(cli): add nak-style fetch + subscribe query primitives
Third batch of nak parity — the fetch-vs-subscribe split of nak's `req`:

- `amy fetch [filter] [--timeout SECS]` — one-shot query: open a
  subscription, collect until every relay sends EOSE (or timeout),
  dedupe by id, sort newest-first, cap at --limit (default 100), exit.
- `amy subscribe [filter] [--timeout SECS]` — live stream: print each
  matching event as it arrives (NDJSON under --json), until timeout or
  interrupt.

Shared filter flags (--kind/--author/--id/--tag/--since/--until/--limit
/--search) are assembled by RawEventSupport.buildFilter; --author/--id
accept npub/nevent/note/naddr or hex (local decode). fetch reuses
Context.drain; subscribe uses the client subscription directly and
verifies each event before printing. Verified against relay.damus.io
(kind:0 by author, kind:1 stream).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:34:24 +00:00
Claude b539609dfe feat(cli): add nak-style event + publish primitives
Second batch of nak parity:

- `amy event --kind N [--content …] [--tags JSON] [--created-at TS]`
  builds and signs an arbitrary event with the active account. Prints
  the signed event by default; `--publish` / `--relay` broadcasts it.
- `amy publish [EVENT-JSON] [--relay …]` broadcasts a pre-made, signed
  event (verified before broadcast; reads stdin when no arg).

Both reuse quartz EventTemplate/NostrSigner.sign and the existing
Context.publish path. New RawEventSupport holds the shared arg/stdin +
relay-target helpers for the raw-event verbs. Verified offline via an
event -> verify round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:30:47 +00:00
Claude 48b988d2f7 refactor(cli): simplify amy dispatch, Context lifecycle, and store handling
Four mechanical simplifications to amy with no change to the public
CLI/JSON contract:

1. Drop the Commands.kt pass-through layer. Main.kt now calls each
   command object directly; the file is repurposed into Router.kt,
   holding a single shared `route(name, tail, usage, routes)` helper.

2. Replace the `Context.open(dataDir)` + `try { } finally { ctx.close() }`
   boilerplate (~46 sites) with `Context.open(dataDir).use { ctx -> }`
   now that Context is AutoCloseable.

3. Remove the reflection-based `storeIsInitialized()` in Context; track
   the lazy event store via `Lazy.isInitialized()` instead.

4. Route every `*Commands.dispatch` through the `route` helper, dropping
   the repeated empty-check + unknown-verb `when` boilerplate.

Net -282 lines. Docs (cli/DEVELOPMENT.md, amy-expert skill + template)
updated to the new wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjEvS812aPLZ6nM2XLobzF
2026-06-21 17:24:00 +00:00
Claude b41160f1cb feat(cli): add nak-style stateless primitives (decode, encode, verify, key)
Add the first batch of nak parity commands to amy — the army-knife
primitives that operate purely on their arguments, with no account or
network. They dispatch before account resolution (like `use`), so they
run with zero `~/.amy/` state:

- `amy decode ENTITY`   NIP-19/21 entity -> JSON
- `amy encode <type> …` raw parts -> NIP-19 entity
- `amy verify [JSON]`   id-hash + signature check (reads stdin)
- `amy key generate|public`  mint a keypair / derive a pubkey

All four are thin wrappers over quartz (Nip19Parser, the NIP-19
entities, Event.verifyId/verifySignature, KeyPair) per the cli
thin-assembly rule. README + ROADMAP updated with a nak-parity matrix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011SapGdtAc1j7woifoCZ9fY
2026-06-21 17:13:05 +00:00
Claude 434ef31f35 feat(profile): "Apps" tab listing a user's nsites & napplets
Adds a profile tab that lists the NIP-5A nsites (15128/35128) and NIP-5D
napplets (15129/35129) a user publishes, surfacing them per-author. Each
row is the inert feed card; opening one launches the sandboxed :napplet
process — the tab never executes applet code.

- UserProfileAppsFeedFilter + UserProfileAppsFeedViewModel (mirror the
  gallery feed; scans LocalCache.addressables for the four manifest kinds
  authored by the user, honoring mute/hidden filters).
- TabApps renders the feed via RefresheableFeedView -> NoteCompose (which
  now has the napplet/nsite cards).
- ProfileScreen: new ProfileTab.Apps wired through the pager.
- Profile relay subscription fetches the manifest kinds (UserProfileAppKinds)
  so the tab is populated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 16:18:04 +00:00
Claude ad351c3127 feat(napplet): inline napplet feed card (sandbox-preserving)
Napplets (15129/35129) had no inline renderer — only the dedicated
browse screen. Add RenderRootNappletEvent/RenderNamedNappletEvent
mirroring the nsite card: an inert Compose card (Text + Button, no
WebView) that shows title/description/source/servers and the declared
capabilities, and launches into the sandboxed :napplet process via
NappletLauncher only on explicit tap. The card never executes applet
code, so feed exposure doesn't widen the trust boundary.

- StaticWebsite.kt: napplet renderers + RenderStaticWebsite gains a
  titleRes and a requires (permissions) row.
- NoteCompose: render branches for RootNappletEvent/NamedNappletEvent.
- LocalCache: index the napplet kinds so feeds can scan them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 16:10:14 +00:00
Vitor PamplonaandGitHub fa11a94613 Merge pull request #3323 from vitorpamplona/claude/powr-workout-interop-r1efh0
Add POWR / NIP-101e strength workout interop (read-only)
2026-06-21 11:52:55 -04:00
Vitor PamplonaandGitHub c503d2ee56 Merge pull request #3324 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:40:30 -04:00
Claude 35af6ca580 fix(fitness): store kind-33401 exercise templates in LocalCache
justConsumeInnerInner's when(event) has no generic addressable fallback —
its else branch logs "Event Not Supported" and drops the event. A kind-33401
ExerciseTemplateEvent arriving from a relay was therefore never stored as an
AddressableNote, so the fetched template never attached to its placeholder
note and the workout card's exercise title never resolved. Dispatch it to
consumeBaseReplaceable like the other addressable definitions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-21 15:39:55 +00:00
vitorpamplonaandgithub-actions[bot] 4aa713e2db chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 15:36:45 +00:00
Vitor PamplonaandGitHub d25f80bcb3 Merge pull request #3321 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:35:14 -04:00
Claude 56d836724f feat(napplet): structured-clone transport + relay.subscribe push channel
Stock napplets (built on @napplet/core) post structured-clone OBJECTS,
not JSON strings, and expect object replies — our shell only forwarded
strings, so their messages were dropped. Bridge the transport so real
napplets interop:

- shell.html bridges object<->string both directions: applet->native
  serializes object envelopes; native->applet parses and posts a
  structured-clone object (what the SDK reads via e.data.type).
- resource.bytes returns a real Blob: the shell rebuilds it from the
  host's base64 bytes+mime before delivering.
- relay.subscribe is push-based: relay.event (per match) then relay.eose,
  keyed by subId, no .result — matching @napplet/shim. New MSG_PUSH IPC
  frame carries unsolicited envelopes the host forwards verbatim;
  relay.close is a fire-and-forget no-op. Delivers the initial snapshot
  then EOSE (a live tail is a follow-up).

Injected shim updated to accept object messages, dispatch subscription
pushes by subId, and use the push-based subscribe. Codec gains
encodeRelayEvent/encodeRelayEose/readSubId with unit tests.

The shell/shim are JS and not covered by the JVM tests — this needs
on-device verification with a playground napplet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 15:34:36 +00:00
Claude 17b3252e76 fix(napplet): correct wire shapes to @napplet/nap message types
Verified the result field names and wire discriminants against the
canonical @napplet/nap 0.15.0 message types and value-types, and fixed
several mismatches:

- identity reads return method-specific fields, not a generic "result":
  getProfile->profile, getRelays->relays, getFollows/getMutes/getBlocked
  ->pubkeys, getList->entries, getZaps->zaps, getBadges->badges.
- getProfile now builds a ProfileData object ({name, displayName, about,
  picture, banner, nip05, lud16, website}) from parsed kind-0 metadata,
  mapping display_name -> displayName, instead of dumping raw content.
- storage wire types are storage.get/set/remove/keys (the SDK functions
  are getItem/setItem/removeItem/keys); storage.keys returns `keys`.
- relay.publish / publishEncrypted carry the unsigned template in the
  `event` field (per @napplet/shim), not `template`.

Shim updated to match; codec round-trip tests lock the shapes.

Still open and documented: the transport is structured-clone objects
(not JSON strings) with a real Blob for resource.bytes, and
relay.subscribe uses relay.event/relay.eose push — the shell relay needs
an object<->string bridge + push channel, with on-device verification.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 14:42:28 +00:00
vitorpamplonaandgithub-actions[bot] eb04e09d26 chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 14:32:14 +00:00
Vitor PamplonaandGitHub 7b93bfb5b9 Merge pull request #3313 from vitorpamplona/claude/recommended-apps-search-3628xk
Add search and follow-list filtering to app recommendations
2026-06-21 10:30:39 -04:00
Vitor PamplonaandGitHub 5f2e879d31 Merge pull request #3314 from vitorpamplona/claude/public-chat-notifications-qmn4gh
Notify on public chat replies without p-tags
2026-06-21 10:29:54 -04:00
Vitor PamplonaandGitHub d34453ec17 Merge pull request #3312 from vitorpamplona/claude/share-options-drawer-70nrq4
Extract share options to reusable bottom sheet
2026-06-21 10:27:36 -04:00
Claude 24638cc50c feat(napplet): implement the identity read API
Wire the upstream identity.* read methods (beyond getPublicKey) to the
active Account, returning JSON gated by the IDENTITY consent:

- getProfile  -> kind-0 metadata content
- getRelays   -> NIP-65 { "<url>": { read, write } } map
- getFollows  -> kind-3 followed author pubkeys
- getMutes    -> NIP-51 mute-list user pubkeys (decrypted)
- getBlocked  -> NIP-51 block-list user pubkeys (decrypted)

getList/getZaps/getBadges route through but degrade to Unsupported for
now; onChanged stays a client-side no-op until the live push channel
lands. Reads are public data only — never key material — and remote/
external signers still self-gate the consent.

Adds NappletRequest.IdentityRead, NappletResponse.Json, a
NappletIdentityGateway collaborator, codec round-trip for identity.*,
the shim methods, and unit tests. commons:jvmTest and the amethyst codec
test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-21 14:26:27 +00:00
Claude 278dac7fea Merge remote-tracking branch 'origin/main' into claude/recommended-apps-search-3628xk
# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/apps/recommendations/ProfileAppRecommendationsScreen.kt
#	amethyst/src/main/res/values/strings.xml
2026-06-21 14:11:37 +00:00
Vitor PamplonaandGitHub 43f8919ce3 Merge pull request #3322 from vitorpamplona/claude/app-recommendations-search-h8bklq
feat: add a search box to the App Recommendations screen
2026-06-21 09:58:32 -04:00
David KasparandGitHub 4a577485cf Merge pull request #3319 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-21 11:56:34 +01:00
Claude eaa6c147e4 feat: add a search box to the App Recommendations screen
The Recommended Apps screen listed every discovered app definition in a
single sorted LazyColumn with no way to filter, so finding a specific app
to recommend meant scrolling the whole list. Users couldn't find a search
because there wasn't one.

Add an always-visible outlined search field below the description that
filters the list by app name (case-insensitive). Rows whose kind 31990
definition hasn't arrived yet have no name to match, so they only appear
when the box is empty. A dedicated empty state shows when a query matches
nothing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EjuK6g3KNkyEEnti6JtDEi
2026-06-21 02:40:00 +00:00
vitorpamplonaandgithub-actions[bot] 3dcc46de2b chore: sync Crowdin translations and seed translator npub placeholders 2026-06-21 02:25:11 +00:00
Vitor PamplonaandGitHub 6227cfd220 Merge pull request #3320 from vitorpamplona/claude/modest-mayer-8mo1zd
feat: add Notification setting to show or hide Messages on the Notification tab
2026-06-20 22:22:58 -04:00
Vitor PamplonaandGitHub da5525c15d Merge pull request #3318 from vitorpamplona/claude/reply-reaction-android-tray-9bkgcx
fix: surface reply, not root, for multi-e-tag reactions in Android tray
2026-06-20 22:21:09 -04:00
Claude 966ba17455 fix: surface reply, not root, for multi-e-tag reactions in Android tray
A kind:7 reaction can carry several `e` tags (e.g. the thread root plus
the actually-reacted-to reply). Per NIP-25 the last `e` tag is the event
being reacted to, but the notification consumer resolved the reacted note
via `originalPost().firstOrNull()`, which picked the root. The tray then
showed the like as if it targeted the root note instead of the reply.

Switch to `lastOrNull()` to match NIP-25 and the in-app reaction card,
which already resolves the target with `note.replyTo?.lastOrNull()`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EEQN8Sy7mQ81BEC3rUu5o
2026-06-21 00:17:46 +00:00
LubuSeb 4e6d8e106c Add URL-scoped comment feeds 2026-06-20 23:26:29 +02:00
Claude bd8beb43e1 feat: add Notification setting to show or hide Messages on the Notification tab
Adds a "Show Messages" toggle to the Display section of Notification Settings.
When disabled, direct/group messages (NIP-17 chats, NIP-04 DMs, and Marmot
group messages) are filtered out of the Notification tab, keeping them only in
the Messages tab. Defaults to on, preserving existing behavior.

The setting is persisted per-account in AccountSettings/LocalPreferences and is
included in the notification feed key so the feed refreshes immediately when
toggled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018FwwcaBRyvnJFWaDLoQ16d
2026-06-20 20:27:32 +00:00
Claude e3d3cebdd4 refactor(notifications): tighten public-chat reply walk and its docs
Audit follow-ups, no behaviour change:

- isNotifiablePublicChatReply bails before allocating the HashSet/ArrayDeque
  scratch when the channel message has no parents (top-level posts — the
  common case), and builds the deque straight from the parent list.
- Correct the docstring: a kind-42 replyTo holds only the immediate parent
  (the channel root is filtered out), so the chain is walked hop-by-hop
  through each cached ancestor — the previous wording implied replyTo already
  held the ancestors.
- Trim the over-long inline comment on the acceptableEvent gate.
- Make the multi-hop test prove what it claims: assert the immediate parent
  is not me, so the walk only passes by climbing to the grandparent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 20:08:08 +00:00
Claude 45656e5476 docs: clarify observeNotes replacement caveat; hoist initial-value scan
Audit follow-up. The previous comment claimed an in-place addressable
replacement "not re-emitting here is fine" because AppRow watches each note;
that conflated per-row content (which does stay live) with list membership and
sort order (which do not re-evaluate on a kind-31990 replacement). Documents
the actual behavior and why it is acceptable. Also hoists the nested
remember{} initial-value cache scan out of the collectAsStateWithLifecycle
argument for readability; no behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 20:00:33 +00:00
Claude 8c56bc64c8 feat(fitness): render kind-33401 exercise templates as a card
A standalone POWR exercise template (kind 33401) opened via naddr, a quote,
or in a thread previously fell through to RenderTextEvent, showing only its
bare instruction text with no title. Add ExerciseTemplateDisplay (equipment
icon, title, equipment/difficulty subtitle, instructions via the rich-text
pipeline) and dispatch to it from NoteCompose.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 19:56:49 +00:00
Claude b43c1c9eda fix(napplet): align shell to verified @napplet/shim 0.15+ contract
Reverse-engineered the authoritative @napplet/shim (npm v0.16.0) and
corrected the napplet host to its real wire contract. The prior commit
carried guessed method names that would break real ecosystem napplets.

- Signing model: napplets never get a sign() (per upstream "signing and
  encryption are mediated by the shell"). Dropped keys.signEvent /
  keys.nip04* / keys.nip44*. relay.publish now takes an UNSIGNED template
  and the broker signs it as the user, returning the signed event;
  added relay.publishEncrypted (shell encrypts + signs + publishes).
- keys -> keyboard/command actions (registerAction/unregisterAction/
  onAction), client-side no-op stubs (not yet wired to the host keyboard).
- storage: get/set/remove -> getItem/setItem/removeItem; added storage.keys
  end-to-end (protocol, broker, DataStore, shim).
- resource.bytes returns a Blob (shim builds from {bytes, mime}).
- shell.supports gains optional protocol arg; added shell.ready/onReady/
  services stubs.
- relay.subscribe wired (initial matches; live tail still a follow-up).
- value.payInvoice and upload.blob kept as clearly-marked Amethyst-specific
  extensions (no upstream equivalent; real napplets never call them).

Broker now defers per-signature consent to remote/external signers via a
signsAsUser flag (publish/publishEncrypted) instead of an IDENTITY/KEYS
capability check. Tests updated; commons:jvmTest and the amethyst codec
round-trip test pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 19:39:36 +00:00
Claude b835819f97 refactor: de-duplicate shareId and correct ShareActionRows docs
Hoist the AddressableNote-vs-id shareId computation out of the two image
share rows, and fix the KDoc rationale: "Share as Image" shares a local PNG
(no relay write); the rows are hidden on private rumors because they expose
the note's content publicly, not because they publish an e-tag.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 19:37:00 +00:00
Claude a8feb88c2e refactor: derive app list from observeNotes, drop tick + rescan
The screen previously used observeNewEvents purely as an invalidation tick
to re-scan LocalCache.addressables on every app-definition insertion. Since
observeNotes already seeds with the cached kind-31990 notes and re-emits as
new ones arrive, the candidate list now derives straight from those notes:
the appDefinitionsTick counter and the repeated full-cache rescan are gone.
Initial value is seeded from the current cache snapshot to preserve the
first-frame content.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 19:30:33 +00:00
Vitor PamplonaandGitHub cdfe007fb4 Merge pull request #3309 from vitorpamplona/claude/remove-alt-tags-events-786j5p
Remove unused NIP-31 alt tag surrogate code and clean up imports
2026-06-20 15:26:00 -04:00
Claude e2f2c4654e feat(fitness): resolve POWR exercise templates (kind 33401)
Render real exercise names in POWR workout cards instead of slug-derived
labels by fetching the referenced kind-33401 exercise templates.

- ExerciseTemplateEvent (kind 33401, addressable) with title/format/
  format_units/equipment/difficulty accessors; registered in EventFactory.
- WorkoutRecordEvent now implements AddressHintProvider: linkedAddressIds
  (deduped 33401 + 33402 coordinates) seed the gatherer so the card
  re-renders when a template arrives, and addressHints feed the relay-hint
  index with the relay.powr.build hints so the fetch reaches where POWR
  published the templates.
- WorkoutDisplay resolves each exercise via LoadAddressableNote +
  observeNoteEvent<ExerciseTemplateEvent>, showing the template title once
  fetched and the slug until then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 19:24:25 +00:00
Claude 2ec5c81fd3 perf(notifications): keep the public-chat reply gate inline so it short-circuits
Hoisting the p-tag/public-chat-reply check into a pre-computed val made it
eager: the tag scan (and, for channel messages, the reply-chain walk) ran on
every Note in the cache, even the overwhelming majority rejected by the cheap
`kind in NOTIFICATION_KINDS` check. Inline it back into the && chain in its
original 4th position so that kind check short-circuits ahead of it, and order
the OR so the cheaper tag scan runs before the reply-chain walk.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 19:22:23 +00:00
Claude 445a20b2f3 fix: preserve user-provided media descriptions for accessibility
The blanket NIP-31 alt removal also dropped genuine accessibility
descriptions (image descriptions for the blind) on a few media paths where
the user's caption was only stored in the event-level alt tag. Restore them
via the proper, non-deprecated fields:

- NIP-94 FileHeaderEvent (kind 1063) and NIP-17 encrypted file headers
  (kind 15): write the `alt` tag only when the user actually provided a
  caption (the NIP-94 accessibility description), never the old boilerplate
  fallback.
- MIP-04 encrypted group media (kind 9): route the caption into the imeta
  `alt` field via buildMip04IMetaTag instead of an event-level alt tag.

Re-adds the narrowly-scoped TagArrayBuilder.alt() / AltTag.assemble() write
helpers (documented as accessibility-only, not for deprecated NIP-31
boilerplate). Boilerplate alt tags on all other event kinds remain removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
2026-06-20 19:19:16 +00:00
Vitor PamplonaandGitHub 6235c407c3 Merge pull request #3311 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-20 15:16:31 -04:00
Claude 21805f6857 refactor: use indexed observeNewEvents for app definition ticks
Replaces the global newEventBundles firehose (woke on every new event of
every kind, then filtered for AppDefinitionEvent client-side) with the
indexed LocalCache.observeNewEvents for kind 31990, so the cache delivers
only app-definition insertions to the recompute tick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 19:13:30 +00:00
vitorpamplonaandgithub-actions[bot] 64eeeb84bc chore: sync Crowdin translations and seed translator npub placeholders 2026-06-20 19:11:55 +00:00
Vitor PamplonaandGitHub 1530cae4eb Merge pull request #3308 from vitorpamplona/claude/localization-unused-strings-3xsyhu
i18n: localize Marmot group chat strings and remove unused resources
2026-06-20 15:09:57 -04:00
Vitor PamplonaandGitHub e9bc3dcf0f Merge pull request #3310 from vitorpamplona/claude/android-tray-dismiss-vbdhve
Auto-dismiss notifications when events are read in-app
2026-06-20 15:05:09 -04:00
Claude 81a1c3b4c4 feat: auto-dismiss tray notifications when the event is read in-app
Tray notifications were only suppressed for new events while the app was
foregrounded (MainActivity.isResumed); notifications already posted while
backgrounded lingered even after the user opened the app and viewed them.

Per-event tray notifications are keyed by the triggering event's
id.hashCode(), so when a note bearing such an id is marked read in-app
(loadAndMarkAsRead's onIsNew branch), cancel the matching notification.
Childless group summaries are cleaned up so the tray doesn't keep an
empty summary around. Covers replies/mentions (Notification feed via
NoteCompose) and DMs (ChatMessageCompose); grouped reaction/zap cards
have no 1:1 event mapping and are left untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XsnDifyUtaWUq4wGu4smZf
2026-06-20 17:51:54 +00:00
Claude 5ca44e277f feat(napplet): align with the upstream napplet SDK (envelope, namespaced API, domains)
Acts on the ecosystem audit so real napplets built against @napplet/web can run.

Wire: switch to the upstream envelope {type:"<domain>.<action>", id} →
{type:"…​.result", id, ok, …} across the JS shim, NappletProtocolJson, and the host
shuttle (host forwards the verbatim envelope, injects id on the reply).

API: rewrite the injected window.napplet.* to the namespaced SDK surface —
shell.supports, identity.getPublicKey (+onChanged stub), keys.{signEvent,nip04*,
nip44*}, relay.{publish,query,subscribe}, storage.{get,set,remove}, value.payInvoice,
resource.{bytes,bytesAsObjectURL}, upload.blob. subscribe currently returns the
initial matches via query (live tail is a follow-up).

Capabilities: split to the domain model — SHELL, IDENTITY, KEYS, RELAY, STORAGE,
VALUE, RESOURCE, UPLOAD (was IDENTITY/RELAY/WALLET/STORAGE/NET). shell.supports is
answered with no consent, reflecting declared+brokered domains; keys (signing) split
from identity (pubkey); signer-self-gating now covers both.

New ops: resource.bytes (https/data, broker-fetched, Tor-routed, consent-gated).
upload is wired end-to-end but its Android Blossom gateway is left unprovided
(Unsupported) pending the Uri + auth-event + server-selection integration.

Codec moved to java.util.Base64 (real on minSdk 26 and in JVM tests). Permissions
screen + capability labels updated to the 8 domains; new consent/label strings
localized. Broker + codec + capability tests updated/added.

:commons:jvmTest, :amethyst:testPlayDebugUnitTest (codec), and
:amethyst:compileFdroidDebugKotlin pass; spotless clean. See
plans/2026-06-20-napplet-ecosystem-audit.md (Update section) for the remaining gaps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 16:54:06 +00:00
Claude c06d8eb7f7 refactor: make the Share drawer share-only; 3-dot menu keeps its copy rows
Per review, the shared element is now just the three true Share options
(browser link, image file, image URL) in ShareActionRows, surfaced by the
ShareOptionsBottomSheet drawer.

- The 3-dot menu keeps its four copy-to-clipboard rows inline, exactly as
  before; its share section is now a single "Share" row that opens the drawer
  instead of doing the share directly.
- NoteDropDownMenu owns the drawer state and renders the sheet in place of the
  menu dialog, so the existing direct callers (MultiSetCompose,
  MessageSetCompose) need no changes.
- The reaction-row Share button opens the same drawer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 16:49:52 +00:00
Claude b9ac370f4f feat(fitness): render POWR kind-1301 strength workouts
POWR and RUNSTR both publish kind 1301 but with incompatible tag schemas.
A POWR event previously rendered with the raw "33401:...:back-squat-bb"
coordinate as its activity label, no duration, and none of the set data.

Parse the POWR / NIP-101e dialect in quartz and render it in Amethyst:

- type tag for the activity (strength/circuit/emom/amrap), preferred over
  the RUNSTR exercise verb; coordinate-form exercise tags no longer leak as
  a verb.
- start/end session timestamps -> derived duration; completed flag.
- structured per-set exercise tags (kg weights, reps, rpe, set_type),
  grouped per exercise template with volume/top-weight aggregates.
- WorkoutDisplay now shows Exercises/Sets/Volume stats and a per-exercise
  breakdown (e.g. "Back Squat Bb -> 3 x 8 x 84 kg") in the viewer's unit.

Rendering interop only; Amethyst still publishes the RUNSTR-canonical form.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QJwXz6CWez8r7trgHXT545
2026-06-20 16:47:39 +00:00
Claude 9a43e03748 feat: add top-nav feed filter to recommended apps screen
Wires the shared FeedFilterSpinner into the Recommended apps (NIP-89)
screen so it matches the other feed screens. The selection persists per
account via a new defaultAppRecommendationsFollowList setting and resolves
through the existing topNavFilterFlow machinery. App definitions only carry
an author dimension, so the Follows-style filters narrow the list to apps
made by those authors (matchAuthor); hashtag/relay/community variants are
no-ops on apps, as expected. Combines with the local text search and adds a
filter-empty message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 16:47:23 +00:00
Claude 18f736abf7 refactor: stop writing deprecated NIP-31 alt tags on events we create
The NIP-31 event-level "alt" tag is deprecated, so Amethyst no longer
emits it on any event it builds. Removed all `alt(...)` builder calls and
`AltTag.assemble(...)` insertions across every event kind in quartz (and
the few app-side builders), along with the now-unused `ALT`/`ALT_DESCRIPTION`
companion constants and the `TagArrayBuilder.alt()` / `AltTag.assemble()`
write helpers.

Reading alt tags from incoming events is kept (AltTag.parse/match,
TagArray.alt(), Event.alt()) for interop with clients that still send them,
and the imeta media accessibility `alt` field (NIP-92/94) is untouched.

Updated/removed tests that asserted alt-tag presence and refreshed the
deterministic event-id/sig golden masters in UpdateMetadataTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014xAESAz1H1VNjmQpMVqBXj
2026-06-20 16:46:25 +00:00
Vitor PamplonaandGitHub e20d7d2f0f Merge pull request #3307 from vitorpamplona/claude/custom-emojis-push-notifications-8n3h87
feat(notifications): render NIP-30 custom emoji reactions as avatar badge
2026-06-20 12:43:27 -04:00
Claude 9cafbf02cc feat(notifications): notify on public chat replies without a p-tag
Public chats (NIP-28, kind 42) routinely reply to a user without adding a
`p` tag, so the existing mention gate (Event.isTaggedUser) silently dropped
them from both the in-app Notifications feed and Android tray push.

Add NotificationFeedFilter.isNotifiablePublicChatReply: a cache-only check
that walks a channel message's reply chain for one of the user's own
messages — covering a direct reply to my message ("the previous message was
mine") and later messages in a thread I'm already part of ("an active
thread"). It is bounded (depth + visited-set) and reads only Note.replyTo,
so the push dispatcher and the feed can both consult it without loading the
account or decrypting anything.

Wire it as an OR alongside the p-tag gate in the three relevance sites that
share the rule — NotificationFeedFilter.acceptableEvent, the
NotificationDispatcher observer predicate, and EventNotificationConsumer —
while keeping tagsAnEventByUser as the scoping AND so unrelated channel
chatter never leaks through, even in Global mode.

Route ChannelMessageEvent to its own tray handler: reply-to-me renders as a
threaded reply (with inline reply action) grouped by channel so a busy room
collapses into one notification; a pure p-tag citation still renders as a
mention. Muting a thread suppresses it via the existing isAcceptable gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxDVCSWe1RwZ51vABBwqbG
2026-06-20 16:39:54 +00:00
Claude 62ca086302 feat: open a Share options bottom drawer from the reaction-row Share button
Tapping Share in a note's reaction row now opens a bottom drawer with the
same Copy & Share options as the 3-dot menu (Copy Text, Copy Author ID,
Copy Note ID, Copy raw JSON, Share link, Share as Image, Share as Image
URL) instead of jumping straight to the system share sheet.

The seven rows are extracted into a shared ShareCopyActionRows composable so
the 3-dot menu and the new ShareOptionsBottomSheet stay in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jc7PP3PLwT4spvjB2c72pk
2026-06-20 16:28:29 +00:00
Claude 2536a909fc i18n: localize Marmot group chat strings and remove unused resources
Extract hardcoded user-facing strings into string resources so they can be
translated, and delete string resources that are no longer referenced
anywhere in the codebase.

Localization (new string/plurals resources + stringRes/pluralStringResource
call sites):
- Marmot (MLS) group chat feature: list, chat, info, create, and edit
  screens, plus dialogs and toasts.
- OnchainSection (Copy/Send), DvmContentDiscoveryScreen (pay invoice),
  OtsSettingsSection (explorer API settings). Reused existing generic
  strings (back, cancel, save, remove, leave, description, members, send,
  clear) where available.

Cleanup:
- Removed 367 string/plurals resources with no remaining references, along
  with their translations across all locale files. References were resolved
  across Kotlin/Java (including multi-line R.string wraps), XML, gradle
  resValue, and intra-resource @string lookups to avoid removing live
  strings.

Verified with :amethyst:compilePlayDebugKotlin, compileFdroidDebugKotlin,
and processPlay/FdroidDebugResources (aapt resource linking) plus
spotlessCheck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SxvNWmNASbvna4CD5QJFo9
2026-06-20 16:25:34 +00:00
Claude 3cfc6f10e1 feat: add local search to recommended apps screen
Adds an in-screen text filter at the top of the Recommended apps
(NIP-89 kind 31990) screen that filters the loaded app list by name
and description as the user types, with a clear button and an
empty-results message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8oLe5cMzSY8AzTiULJZiW
2026-06-20 16:23:50 +00:00
Claude 0a663bc335 feat(notifications): render NIP-30 custom emoji reactions as avatar badge
Custom-emoji (NIP-30) reactions arrive with content ":shortcode:" backed by
an ["emoji", shortcode, url] tag. Previously the notification showed the raw
":shortcode:" text in the title. Since notification text strips ImageSpans,
the emoji image is now composited as a badge on the reactor's avatar
(largeIcon) and the title keeps just the author's name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UvWKvm3YW6hS5gsJSqePaG
2026-06-20 16:18:02 +00:00
Claude e54c78a6ea docs(napplet): ecosystem-compatibility audit vs upstream SDK / demo runtimes
Audits the implementation against napplet/naps (specs), napplet/web (@napplet/shim
SDK) and kehto/web (reference runtime + playground). Finding: the security core
(process isolation, verified blobs, consent, ledger, permissions UI) is solid and
ahead of what the demo runtimes specify, but the edge layer is not wire-compatible
with the ecosystem — upstream uses a namespaced window.napplet.* and a
{type:"domain.action", id} envelope, we use a flat API and {id, payload:{op}}, and
we lack the mandatory shell.supports() plus the resource/upload/keys domains. So no
real ecosystem napplet runs as-is. Doc includes a coverage scorecard and a
prioritized path to compatibility (envelope + namespaced shim first).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 16:13:12 +00:00
Claude 6432737424 i18n(napplet): localize all user-facing napplet strings
Moves every inline English literal in the napplet UI to string resources:

- Consent dialog: operation summaries (per op), capability label, button labels,
  and the title fallback now come from strings.xml. The payment amount is a proper
  <plurals> (sat/sats) via pluralStringRes.
- Permissions screen + Napplets list: capability names/descriptions, empty states,
  and the untitled-napplet fallback are localized.
- Host toasts (invalid napplet / WebView too old) localized.
- New NappletCapabilityLabels.kt maps each capability to shared label/description
  string resources, reused by both the consent dialog (getString) and the
  permissions screen (stringResource), so there's one source of truth.

Programmatic strings that cross the API boundary to the applet's JS (broker
Failed reasons) and log messages are intentionally left in English.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:56:42 +00:00
Claude c999d2ac73 feat(napplet): permissions management screen
A modern Material3 screen to review and revoke the permissions napplets hold.

Data layer (commons, tested): NappletPermissionStore gains all() (enumerate
persisted grants by coordinate) and remove(coordinate, capability); the ledger
gains allPersistedGrants() and revoke(identity, capability). DataStore actual
implements both (capability is the final space-delimited token of each key).

UI (amethyst): NappletPermissionsScreen renders one ElevatedCard per napplet —
resolved title + author, and a row per capability with an icon, label, and a
control: a Switch (Allowed/Blocked) for normal capabilities, or a "Blocked"
indicator for per-use ones (payments only ever persist a DENY). Each row has a
revoke action; each card a "Forget this napplet" (revokeAll). Empty state with a
shield. Reads/writes the same DataStore the broker uses, so changes take effect
immediately. Reached via a "Manage permissions" action on the Napplets top bar
(Route.NappletPermissions).

commons ledger tests added for allPersistedGrants + single-capability revoke.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:28:28 +00:00
Claude 916a624ddb feat(napplet): capability-aware consent — per-payment, signer-aware identity, foreground-only
Refines the uniform consent model now that wallet + identity carry different risk.

Payments (WALLET): NappletCapability.requiresPerUseConsent — every payInvoice
re-prompts (with the decoded sats amount); the dialog drops "Always allow" and the
broker downgrades any always/session grant to one-shot, so a payment grant is
never persisted. No silent spend.

Identity: gated by us only when Amethyst holds the key (NostrSignerInternal). For
remote (NIP-46) / external (NIP-55) signers the broker defers to the signer's own
per-request consent instead of double-prompting — while still honoring a standing
per-napplet DENY and the requires declaration. The sign prompt shows a kind +
content preview.

Foreground-only execution: NappletHostActivity pauses the WebView's JS/timers in
onPause and resumes in onResume, so a backgrounded applet can't fire a
sign/decrypt/pay request whose prompt would be confused with Amethyst's own UI.
This is the precondition that makes deferring identity to an external signer safe.

commons broker tests added: wallet prompts every time and is never persisted;
external signer defers identity without prompting but still honors a standing DENY.

:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 15:15:10 +00:00
Vitor PamplonaandGitHub b7aad6f61c Merge pull request #3306 from vitorpamplona/claude/gracious-bohr-2r07fo
NIP-22: Reply to Amethyst threads as kind 1111 Comments
2026-06-20 11:14:35 -04:00
Claude 96c5d9bcb6 feat: reply with kind 1111 to new Amethyst kind-1 thread roots
When replying to a note that is a kind 1 TextNoteEvent, is the root of a
new thread (no e-tags), and was itself posted from Amethyst (NIP-89
client tag), build a NIP-22 kind 1111 CommentEvent instead of a kind 1
reply. Forks keep using kind 1.

Applies across all kind-1 reply paths: the Android composer
(ShortNotePostViewModel), the notification quick-reply
(NotificationReplyReceiver), and the desktop composer (ComposeNoteDialog).

Adds Event.isClient / TagArray.isClient helpers (NIP-89, case-insensitive)
with unit coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V7RyevA6jL1NuY7uev2agS
2026-06-20 15:00:04 +00:00
Claude 075be5f8b7 feat(napplet): wallet (NWC) payment + live relay query; defer inter-applet
#1 WALLET — PayInvoice now pays via the user's connected NWC wallet
(account.sendZapPaymentRequestFor, wrapped suspend with a 60s timeout). The
gateway returns the preimage on success and throws (→ Failed) on no-wallet,
wallet error, or timeout, so an applet never wrongly believes a payment landed.
The consent dialog decodes the invoice and shows the amount in sats
(LnInvoiceUtil). Gated as before: must declare `value`/`wallet`, then consent.

#3 live relay query — QueryEvents now does a bounded live fetch
(INostrClient.fetchAll, EOSE/8s timeout) across the user's read relays, merged
with LocalCache, deduped, newest-first, limit-respected — instead of cache-only.

#2 inter-applet — deferred per design review. It needs new architecture
(multi-applet hosting + an archetype registry), not just a gateway, and would
risk forking the upstream NAP-INC/INTENT wire format. Surveyed upstream
napplet/naps and wrote the design + prerequisites in
amethyst/plans/2026-06-20-napplet-inter-applet.md.

:amethyst:compileFdroidDebugKotlin passes; spotless clean. Wallet + live query
need on-device verification (real NWC wallet / relays).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 14:40:46 +00:00
Vitor PamplonaandClaude Opus 4.8 1223f5b832 fix(media): HTTP/2 keepalive ping to stop stale-connection image stalls
The media OkHttp client keeps 32 connections warm in a 5-minute pool but,
unlike the relay client, set no pingInterval. Hosts like blossom.primal.net
silently drop idle HTTP/2 connections between feed-scroll bursts. OkHttp then
pulls a dead connection from the pool and the request stalls until the read
timeout (30s wifi / 90s mobile), which users see as "the first image after a
pause takes forever".

Device MediaHttp logs showed the signature repeatedly:
  blossom.primal.net total=30000ms ttfb=-1ms conn=reused error=SocketTimeout

Add a 10s HTTP/2 keepalive ping so dead pooled connections are detected in
seconds and retryOnConnectionFailure re-issues on a fresh one, mirroring what
the relay client already does.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 10:24:50 -04:00
Vitor PamplonaandGitHub ee41ac7b7e Merge pull request #3305 from vitorpamplona/claude/sharp-dijkstra-j6do4j
Consolidate Crowdin sync and translator seed into single PR
2026-06-20 10:18:35 -04:00
Claude 86a19012b5 test(napplet): JVM unit tests for the protocol codec; move it off org.json
The codec is the one place the trust boundary parses untrusted applet input, so
it deserves tests — but Android stubs `org.json` in JVM unit tests
(returnDefaultValues), making the previous org.json-based codec untestable
off-device. Rewrites NappletProtocolJson on kotlinx.serialization (a real JVM
JSON impl the app already uses for @Serializable routes), which also drops a
runtime Android dependency from the parser.

Adds NappletProtocolJsonTest (20 cases): decode of every request op, unknown op
→ null, malformed/missing-field → throws (caught by the broker as Failed), encode
of every response variant, null→JsonNull for storage/paid, and a SignedEvent
round-trip back through Event.fromJson.

:amethyst:testPlayDebugUnitTest passes (20/0/0); spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 14:13:11 +00:00
Claude 48a88f136f ci: combine Crowdin translation and translator-seed PRs into one
Previously the Crowdin workflow ran two independent jobs that each opened
their own pull request: the crowdin/github-action sync PR ("New Crowdin
Translations") and the peter-evans seed PR ("Seed translator npub
placeholders").

Collapse them into a single job: the Crowdin action now only downloads
translations into the working tree (push_translations/create_pull_request
disabled), the seed script edits translators.json in the same checkout, and
one create-pull-request step opens a single combined PR on
l10n_crowdin_translations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kxm4Pq3rm4doVLqJ2LaXtr
2026-06-20 14:10:35 +00:00
Vitor PamplonaandGitHub 240d600091 Merge pull request #3302 from vitorpamplona/chore/seed-translators
Seed translator npub placeholders
2026-06-20 09:58:17 -04:00
Vitor PamplonaandGitHub abccc1ea30 Merge pull request #3303 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-06-20 09:58:01 -04:00
Claude b422274140 feat(napplet): capability enforcement, read/storage capabilities, nsite host wiring
Closes the highest-leverage gaps from the completeness report (items 2–5).

Capability enforcement (#2):
- The broker now refuses any request whose capability is not in the manifest's
  `requires`. The host resolves `requires` to a declared capability set and sends
  it with every IPC request; the broker denies undeclared capabilities before any
  consent prompt. Per-operation consent summaries added for the new ops.

Read capability (#3):
- New QueryEvents request (RELAY capability) → NappletRelayGateway.query, answered
  from LocalCache (account.cache.filter). window.napplet.queryEvents(filter) added.

nsite host wiring (#4):
- NappletLauncher generalized to launch any NIP-5A site from paths+servers, so
  nsites (kinds 15128/35128) open in the sandbox too. The nsite card
  (StaticWebsite) gets an "Open" button; nsites declare no capabilities, so the
  broker refuses everything and they render as inert static content.

Storage + wallet (#5):
- STORAGE fully implemented: StorageGet/Set/Remove + DataStoreNappletStorage,
  namespaced per applet coordinate. window.napplet.storage.{get,set,remove}.
- WALLET modeled with PayInvoice + NappletWalletGateway, but kept Unsupported (no
  gateway provided) — no money path ships until verified end-to-end.
- Inter-applet messaging and live (non-cache) relay query remain v2.

commons broker tests cover declaration enforcement, query, and storage round-trip.
:commons:jvmTest and :amethyst:compileFdroidDebugKotlin pass; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 13:50:19 +00:00
Crowdin Bot b4a60e281d New Crowdin translations by GitHub Action 2026-06-20 13:48:56 +00:00
vitorpamplonaandgithub-actions[bot] 67af8fc3bf chore: seed translator npub placeholders from Crowdin 2026-06-20 13:47:37 +00:00
Vitor PamplonaandGitHub 4c7076753c Merge pull request #3301 from vitorpamplona/claude/apps-feed-version-updates-bofv60
Keep NIP-82 release versions live via LocalCache observer
2026-06-20 09:47:13 -04:00
Claude 2bccab797c feat(napplet): relay subscription to discover napplet manifests
NappletsScreen previously showed only what was already cached. Adds a lightweight
discovery subscription so the list actually populates:

- NappletsFilterAssembler / NappletsFilterSubAssembler (SingleSubEoseManager):
  one REQ per read relay for kinds 15129/35129, deduped to a single subscription
  per account. No follow-list/feed-state machinery — the screen reads LocalCache
  directly, so the query state carries only the account + scope.
- Registered as an app-lifetime singleton in RelaySubscriptionsCoordinator (the
  EOSE manager opens its relay sub at construction, so it can't be per-screen).
- NappletsScreen invokes NappletsFilterAssemblerSubscription, which subscribes
  on STARTED and tears down after the lifecycle grace window.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 03:26:15 +00:00
Claude 9da1df57b9 fix: narrow app release observer to the app's i-tag
The version-chip observer filtered releases by author only, so an
author with many apps pulled every release they ever published into the
observer's working set. Narrow the filter on the release `i` tag (the
app id) so only this app's releases are loaded.

A blind limit is avoided on purpose: LocalCache.filter applies
take(limit) before sorting by created_at, so a limit could drop the
latest release the chip needs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 03:25:05 +00:00
Claude cbbba279a7 refactor: use indexed LocalCache.observeNotes for app version chip
Replace the global newEventBundles + full-cache rescan with an
index-driven LocalCache.observeNotes(kind 30063 / author) observer, the
established idiom (NestLobbyScreen, OpenPollsState, DvmContentDiscovery).
The FilterIndex only wakes the observer when a matching release is
inserted, instead of re-scanning the whole addressables map on every
event app-wide.

Extract the NIP-82 release parsing (dTag-prefix + kind-30063 collision
handling) into shared helpers reused by findLatestNip82Release and
findAllNip82Releases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 03:12:02 +00:00
Claude d4899c2afc feat(napplet): UI entry point, Tor-routed blob fetch, sandbox process isolation
UI entry point:
- NappletsScreen: a "Napplets" drawer item lists napplet manifests in the local
  cache (NIP-5D kinds 15129/35129) and opens the selected one in the sandboxed
  host. Wired as Route.Napplets with a NavBarItem + drawer entry.

Sandbox process isolation (security fix):
- Application.onCreate runs in every process, so the :napplet process was building
  AppModules and initiate() was loading the account + constructing the signer there
  — defeating the "no keys in the sandbox" guarantee. Amethyst.onCreate now detects
  the :napplet process and skips AppModules entirely, leaving `instance` unset so
  any accidental use fails fast.

Tor/proxy-aware blob fetch:
- The host's OkHttpClient now routes Blossom blob fetches through the user's Tor
  SOCKS proxy when active. The port is resolved in the main process by the launcher
  and passed via the Intent, so the sandbox process never needs the account-bound
  HTTP stack.

:amethyst:compileFdroidDebugKotlin passes; spotless clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-20 03:10:12 +00:00
Claude ed23f81f3a fix: refresh NIP-82 app version chip when a new release arrives
The Apps feed version chip read the latest SoftwareReleaseEvent (kind
30063) once via a produceState keyed only on the app event id. NIP-82
releases point back to the app through an `i` tag rather than an `a`
tag, so they are never indexed as replies to the app note and never
ping its flows. As a result a newer release arriving while the card was
visible left the chip showing the old version.

Re-scan LocalCache on every new-event bundle (the same pattern used by
the calendar RSVP/calendar scans) so the chip updates without a manual
refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EbCTsBoGtBCJ1rTKCcar6w
2026-06-20 00:45:43 +00:00
Vitor PamplonaandGitHub 2f46293a69 Merge pull request #3300 from vitorpamplona/chore/seed-translators
Seed translator npub placeholders
2026-06-19 20:01:01 -04:00
vitorpamplonaandgithub-actions[bot] fcebff50cb chore: seed translator npub placeholders from Crowdin 2026-06-19 23:59:48 +00:00
Vitor PamplonaandGitHub 7657bda750 Merge pull request #3299 from vitorpamplona/claude/url-parser-punctuation-brvq35
Keep balanced closing delimiters in URLs
2026-06-19 19:59:25 -04:00
Claude daae12b53d feat(napplet): Android sandbox host — isolated process, broker IPC, consent
Implements the Android side of the napplet/nsite trust boundary on top of the
commons core. The applet runs in a separate OS process holding no keys; every
dangerous operation is brokered to the main process and gated by user consent.

:napplet process (no secrets):
- NappletHostActivity: hardened WebView (no file/content access, no DOM storage,
  mixed-content blocked, SafeBrowsing on), applet served into an opaque-origin
  sandboxed iframe, manifest blobs served already-verified via
  shouldInterceptRequest with a default-deny CSP (connect-src 'none' = no direct
  network), and a window.napplet.* shim bridged over an origin-restricted
  WebMessageListener.

Main process (holds the signer):
- NappletBrokerService: bound Messenger service running the commons NappletBroker
  against the live account; exported=false + UID check. Builds the broker per
  request so account switches are honored; relay publish via the account's
  computed broadcast relays.
- NappletConsentActivity + NappletConsentCoordinator: capability-consent dialog
  with a suspend bridge; fails closed on dismissal.
- DataStoreNappletPermissionStore: persistent grant store.

Shared edge: NappletProtocolJson (JSON codec), NappletIpc (Messenger contract),
NappletLauncher (packs a verified manifest into the host Intent), shell.html.

Adds androidx.webkit (Apache-2.0) for the origin-restricted message bridge — a
plain @JavascriptInterface leaks into every frame and would break the boundary.
Manifest declares the :napplet activity, the consent activity, and the broker
service. :amethyst:compileFdroidDebugKotlin passes; on-device verification and a
UI entry point are the remaining steps (see the plan doc).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-19 23:50:58 +00:00
Claude da336897c2 fix: keep balanced closing delimiters and inline commas in detected URLs
The URL detector stripped a single trailing punctuation char unconditionally,
which dropped the closing ")" from legitimate URLs such as
https://en.wikipedia.org/wiki/Bitcoin_(disambiguation).

Make the trailing strip balance-aware: a trailing ")", "}" or "]" is kept when
the URL contains its matching opener (balanced), and only stripped when it is
unbalanced wrapping/sentence punctuation (e.g. "(see example.com)" or
"http://test.com)"). Commas without surrounding spaces were already kept inside
paths; this also adds "]" to the begin/end punctuation sets so an unbalanced
bracket is handled symmetrically with parens and braces.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AzZzVcMcuzjSdhD3xqCE87
2026-06-19 23:38:44 +00:00
Claude 119442293a feat(napplet): trust-boundary core for sandboxed nsite/napplet rendering
Adds the platform-agnostic core for hosting untrusted napplet (NIP-5D) /
nsite (NIP-5A) web content behind a hard trust boundary, so applet HTML/JS
can never reach the nsec, app storage, or LocalCache.

The Android host runs the WebView in a separate OS process (:napplet) that
holds no secrets and brokers every dangerous operation over IPC to the main
process. This commit lands the verifiable heart of that boundary in commons
commonMain (KMP-pure, fully unit-tested):

- NappletCapability + NAP-domain mapping (default-deny on unknown domains)
- NappletIdentity keyed by addressable coordinate (grants survive updates)
- NappletPermissionLedger / GrantState / store (persistent vs session vs once;
  standing DENY is authoritative)
- NappletRequest/NappletResponse wire protocol (no response carries key bytes)
- NappletBroker: the only holder of the signer; enforces consent, signs as the
  user only, refuses to publish foreign or unsigned events

Architecture, process model, IPC schema, WebView hardening, and consent UX are
documented in amethyst/plans/2026-06-19-napplet-sandbox-host.md. The Android
:napplet process, WebView host, AIDL broker, and consent UI are the next phase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ncMHuBBVHEf7spAoSssde
2026-06-19 23:22:45 +00:00
Vitor PamplonaandClaude Opus 4.8 805f7e8e84 v.1.12.6
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 19:01:59 -04:00
Vitor PamplonaandGitHub 19b1797f2d Merge pull request #3298 from vitorpamplona/claude/blossom-refactor-consolidate-9z9yxw
Centralize Blossom URL and auth header construction
2026-06-19 18:49:18 -04:00
Vitor PamplonaandGitHub ba6196d542 Merge pull request #3297 from vitorpamplona/claude/disappearing-scaffold-overshoot-ew8y84
Fix disappearing bar overshooting on fast reveal fling
2026-06-19 18:49:11 -04:00
Claude eac0daed9c refactor(blossom): centralize Blossom protocol strings in quartz
The Blossom auth-header encoding (`Nostr <base64-event>`), the `/upload`
endpoint path, and the `X-Reason` failure header were each re-derived in
both the commons JVM `BlossomClient`/`BlossomAuth` and the Android
`BlossomUploader`, using two different Base64 APIs. Move these
protocol-level facts into the quartz `nipB7Blossom` package, where the
rest of the Blossom protocol lives:

- `BlossomAuthorizationEvent.toAuthorizationHeader()` / `rawToken()` +
  `AUTH_HEADER_SCHEME`, mirroring NIP-98's
  `HTTPAuthorizationEvent.toAuthToken()` that Blossom auth reuses.
- new `BlossomServerUrl` with `upload()` / `blob()` endpoint builders and
  the `REASON_HEADER` constant.

Both transports now call these helpers instead of hand-building strings.
No behavior change for upload (existing desktop BlossomClientTest still
green); the Android delete URL now omits the trailing dot when no file
extension is known, matching BUD-02's `DELETE /<sha256>`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JJgwV4Y99brVa97v7p3jJb
2026-06-19 22:40:28 +00:00
Claude f089ef9db4 fix: stop DisappearingScaffold bars overshooting their resting edge
The settle/reset animation drove a critically-damped spring with the
fling's leftover velocity. A critically-damped spring does not oscillate
from rest, but when handed an initial velocity in the target's direction
its response still crosses the target once before decaying back. On a fast
reveal fling the top bar's offset shot well past 0 (measured ~+190px in a
test) — rendering the bar sliding below its resting position and springing
back, the "goes beyond its final position and then comes back" wobble that
only appeared on fast flings.

Clamp the settle Animatable to the visible travel range [-limit, 0] via
updateBounds, so hitting an edge ends the animation crisply with no
rebound. Add a regression test that steps the settle under a manual frame
clock and asserts the offset never crosses the resting edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EEGCrB5uRBAZES1PSp4Ctz
2026-06-19 22:38:20 +00:00
Vitor PamplonaandGitHub 977c94e4c6 Merge pull request #3296 from vitorpamplona/claude/napplet-protocol-n2ihd6
Add NIP-5A static-site resolver + NIP-5D napplet support
2026-06-19 18:26:36 -04:00
Claude 0eea00543a feat(cli): add amy napplet fetch for NIP-5D napplets
Extends the CLI to fetch and verify NIP-5D napplet kinds, mirroring `amy nsite`
but adding the napplet-specific runtime checks.

- NappletCommands: `amy napplet fetch AUTHOR [--d ID] | --snapshot EVENT-ID
  [--path P] [--server …] [--relay …] [--out FILE] [--timeout SECS]`. Fetches a
  root (15129), named (35129, via --d), or snapshot (5129, via --snapshot
  <event-id>) manifest; recomputes the NIP-5A aggregate hash and refuses a
  manifest whose `x` tag doesn't match its path tags (`aggregate_mismatch`)
  before touching any blob; then resolves the path with per-blob sha256
  verification. Output adds `requires` (NAP capabilities), `aggregate_sha256`,
  and `aggregate_verified`.
- StaticSiteFetch: new shared helper holding the Blossom download + resolve +
  emit logic, so `nsite` and `napplet` don't duplicate it. NsiteCommands is
  slimmed down to use it (also now reports the manifest `kind`).

Smoke-tested offline: bad-args, help, and dead-relay runs resolving cleanly to
not_found with the correct kind for all three napplet variants (15129/35129/5129)
plus a no-regression check on `nsite fetch`. The aggregate/per-blob verification
logic itself is covered by the quartz unit tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
2026-06-19 22:14:14 +00:00
Claude 2648771429 feat(quartz): implement NIP-5D (Nostr Web Applets / napplets)
Adds the full NIP-5D napplet manifest layer, plus the NIP-5A aggregate-hash
infrastructure it depends on. Follows the nip88Polls package structure (event
class + tags/ + TagArrayExt + TagArrayBuilderExt).

NIP-5A shared infra (nip5aStaticWebsites):
- XTag — the aggregate-hash tag ["x", "<sha256>", "aggregate"].
- SiteAggregateHash — computes/verifies the NIP-5A aggregate hash: sort the
  per-path lines "<hash> <path>\n" lexicographically, concat as UTF-8, SHA-256.
  Pinned by a test against an independently computed sha256sum vector.
- siteAggregateHash() parse + builder extensions.

NIP-5D napplets (nip5dNapplets):
- NappletSnapshotEvent (5129, regular), RootNappletEvent (15129, replaceable),
  NamedNappletEvent (35129, addressable, d-tag) — all built on the NIP-5A
  path/server/title/description/source/x tag set.
- RequiresTag — ["requires", "<bare-nap-name>"] capability declarations.
- NappletManifest interface — uniform accessors (paths/servers/requires/title/
  …) plus computeAggregateHash()/verifyAggregate() shared across the three kinds.
  build() auto-stamps the x aggregate (required for snapshots, recommended for
  root/named).
- Registered all three kinds in EventFactory.

This covers the NIP-5D runtime verification contract end-to-end in quartz:
signature (core Event.verify), per-blob sha256 (StaticSiteResolver.verify), and
the aggregate x-tag (NappletManifest.verifyAggregate). Note the napplet kinds
5129/15129/35129 are distinct from the NIP-5A nsite kinds 5128/15128/35128, so
there is no collision.

Tests: SiteAggregateHashTest (vector + order-independence + tamper) and
NappletEventTest (build/parse round-trip for all three kinds, aggregate
verification, tamper detection, EventFactory routing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
2026-06-19 22:06:49 +00:00
Vitor PamplonaandGitHub 80aa42a599 Merge pull request #3295 from vitorpamplona/claude/lucid-planck-0skkio
Fix metadata bitmap recycling crash on some OEM ROMs
2026-06-19 17:59:46 -04:00
Vitor PamplonaandGitHub b4b21d2aec Merge pull request #3294 from vitorpamplona/claude/brave-sagan-0vp8wh
Improve error message for failed media downloads
2026-06-19 17:48:16 -04:00
Vitor PamplonaandGitHub 575f17fac8 Merge pull request #3292 from vitorpamplona/claude/elegant-goldberg-qzkt7c
Swallow signer exceptions in relay auth and bunker responses
2026-06-19 17:43:29 -04:00
Vitor PamplonaandGitHub 69c918e086 Merge pull request #3291 from vitorpamplona/claude/zealous-gauss-6njlj1
Deduplicate emoji pack entries by shortcode and visibility
2026-06-19 17:42:53 -04:00
Vitor PamplonaandGitHub 1c85064c3f Merge pull request #3290 from vitorpamplona/claude/charming-ritchie-62gfxz
Handle missing camera app gracefully in photo/video capture
2026-06-19 17:40:04 -04:00
Vitor PamplonaandGitHub 9520ee3e5e Merge pull request #3289 from vitorpamplona/claude/busy-planck-pmnfni
Refactor feed sorting to use extension function and fix race condition
2026-06-19 17:38:38 -04:00
Claude 10caf09d47 fix: add descriptive message to media download HTTP check
When saving media to the gallery, a non-2xx response triggered a bare
check(response.isSuccessful), which threw IllegalStateException("Check
failed.") with no context. The error was caught and logged, but the
message was useless for diagnosing failures and produced a generic toast.

Include the URL, HTTP status code, and status message in the check so the
log and downstream error handling explain why the download failed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Cwv4DpPLTyiJP2Thv3H3jR
2026-06-19 21:37:04 +00:00
Claude 1371ba5d77 fix: dedup emoji shortcodes in EmojiPackScreen to prevent LazyGrid crash
An emoji pack can carry duplicate shortcodes: NIP-30 puts no uniqueness
constraint on emoji tags, so foreign packs may repeat them and our own
addEmoji appends without a duplicate guard. The grid keyed items on
"${code}-${priv|pub}", so two same-code entries in the same visibility
bucket produced identical keys (e.g. "kohakucho-pub") and crashed the
LazyVerticalGrid with IllegalArgumentException.

Collapse to one cell per (shortcode, visibility) with distinctBy when
building the list. Beyond fixing the crash this is the correct UX: two
cells with the same shortcode are indistinguishable and share one delete
path (removeEmoji deletes by shortcode, dropping both). Dedup on code +
visibility so a legit public/private pair of the same shortcode survives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01346aiAXBbdg5hMTAGydTqp
2026-06-19 21:31:59 +00:00
Claude 42187bbf28 fix(playback): cap media-session artwork to platform bitmap limit
Some OEM ROMs (e.g. LineageOS/peridot on Android 15) crash with
"cannot use a recycled source in createBitmap" inside
MediaMetadata.Builder.scaleBitmap() when the legacy MediaSession path
sets metadata artwork.

media3 size-limits artwork using Resources.getSystem()'s
config_mediaMetadataBitmapMaxSize, which is unresolvable on these ROMs
and falls back to the full screen width. The over-sized bitmap is then
re-scaled by android.media.session.MediaSession.setMetadata(), and those
ROMs recycle the source bitmap during scaling. media3's
CacheBitmapLoader caches the now-recycled bitmap and reuses it on the
next metadata update, hitting createBitmap() on a recycled source.

Cap decoded artwork via DataSourceBitmapLoader.setMaximumOutputDimension
to the same framework limit the platform compares against (resolved from
the app context, 320dp default), so build() never re-scales the bitmap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2raH4U7FCpK99YMQfGVQS
2026-06-19 21:27:36 +00:00
Vitor PamplonaandGitHub 96b61cc54d Merge pull request #3288 from vitorpamplona/claude/determined-ritchie-g281jh
Fix unstable sort in live activity feeds by snapshotting status order
2026-06-19 17:27:08 -04:00
Claude c8525fa833 fix: handle missing camera app when taking photos/videos
ActivityResultLauncher.launch() throws ActivityNotFoundException on
devices without an app that handles IMAGE_CAPTURE / video capture
intents, crashing the app from a background dispatcher. Wrap the launch
in launchOrToast(), which runs on the main thread, catches the
exception, shows a toast, and dismisses the capture flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VhTdM5SuUo6WMTCsZz6XWG
2026-06-19 21:26:21 +00:00
Claude 889b620b44 fix(quartz): never let signer timeouts crash from auto-signing launches
An ignored external NIP-55 signer prompt surfaces as
SignerExceptions.TimedOutException. Relay auth (NIP-42) signs replies in a
fire-and-forget scope.launch whose host scope (e.g. viewModelScope) carries no
CoroutineExceptionHandler, so an uncaught timeout there reached the platform
default handler and crashed the app ("Could not sign: User didn't accept or
reject in time.").

Guard the launch in RelayAuthenticator so signing failures are swallowed and
logged (re-throwing only CancellationException). Apply the same guard to
NostrSignerRemote's incoming-bunker-response launch, which decrypts untrusted
relay data on a handler-less scope. Add RelayAuthenticatorTimeoutTest covering
the swallowed-timeout and happy-path-still-sends-AUTH cases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RM8zAKJNE8aAQL5nUboso
2026-06-19 21:21:28 +00:00
Vitor PamplonaandGitHub 5d32d49540 Merge pull request #3287 from vitorpamplona/claude/practical-davinci-23zall
Deduplicate emoji packs in selection event
2026-06-19 17:14:45 -04:00
Vitor PamplonaandGitHub 7ed03ae392 Merge pull request #3286 from vitorpamplona/claude/loving-brown-671lbf
Fix ForegroundServiceDidNotStartInTimeException in NotificationRelayService
2026-06-19 17:13:22 -04:00
Vitor PamplonaandGitHub fa9acd408f Merge pull request #3285 from vitorpamplona/claude/brave-mendel-3ehjpy
Handle Health Connect binding failures gracefully
2026-06-19 17:12:11 -04:00
Claude fa7bb365c4 fix: snapshot live-stream status order before sorting to avoid TimSort contract violation
LiveStreamsFeedFilter.sort and DiscoverLiveFeedFilter.sort computed the primary
sort key, convertStatusToOrder(it.event), lazily inside the comparator. That key
reads OnlineChecker.isCachedAndOffline(url), which depends on a moving
five-minute window and on the checkOnlineCache LruCache. A background online
check can mutate that cache while the sort is running, so the same note could
compare as LIVE (order 2) in one pairwise comparison and offline (order 0) in
another. The resulting unstable ordering makes TimSort throw
"Comparison method violates its general contract!".

Snapshot the status order once per item before sorting (matching how
participantCounts/allParticipants are already precomputed) so the comparator
reads stable values.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HwZzCdNMQoRWbgtZrp4SKH
2026-06-19 21:11:10 +00:00
Claude 676b51d35d fix(notif): call startForeground on every start command
The ForegroundServiceDidNotStartInTimeException came from MainActivity.onResume
calling NotificationRelayService.start() on every resume. Each
startForegroundService() re-arms Android's "must call startForeground() within
the timeout" requirement, even when the service is already running and already
foregrounded. The old initializeForeground() early-returned via an
`if (foregroundStarted) return` guard once it had been started once, so later
startForegroundService() calls were never matched by a startForeground() — the
re-armed requirement went unsatisfied and the OS crashed the whole app.

ensureForeground() now runs on every onStartCommand (startForeground() is
idempotent — it just refreshes the existing notification) and rebuilds the
notification with the current relay count so repeated calls don't flicker back
to "connecting". It also stopSelf()s on every promotion-failure path (not only
ForegroundServiceStartNotAllowedException), which clears the OS fgRequired flag
and cancels the pending timeout when promotion genuinely can't happen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hCJDhJmCNkzqb7SaMWQgB
2026-06-19 21:10:27 +00:00
Claude d606b05d24 fix: avoid TimSort contract violation in feed filters
DiscoverLongFormFeedFilter.sort (and ~44 other feed filters/view models)
sorted notes with the live DefaultFeedOrder comparator, which reads
Note.createdAt() on every comparison. When another thread swaps a Note's
event mid-sort (e.g. a newer replaceable/addressable event arriving from
a relay), createdAt() changes between comparisons and TimSort throws
"Comparison method violates its general contract!"
(IllegalArgumentException).

Migrate every amethyst Set<Note>/Iterable<Note> sort from
sortedWith(DefaultFeedOrder) to the existing sortedByDefaultFeedOrder()
helper, which snapshots createdAt() once per note so the comparator stays
consistent. The pinned-chatroom comparator in ChatroomListKnownFeedFilter
is given the same snapshot treatment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011Sh5XNLssw9GJNxjkxZcRS
2026-06-19 21:09:27 +00:00
Claude ba6b01a715 refactor: dedupe emoji pack addresses with distinct()
Address is a data class over (kind, pubKeyHex, dTag), the same fields
toValue() encodes, so distinct() dedupes on exactly the grid key without
the redundant toValue() projection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fxWguG7dXgwwe2Et3JBjR
2026-06-19 21:09:18 +00:00
Claude f529924f77 fix: guard Health Connect permission check against service-bind failures
Some OEM builds (e.g. ITEL S665L) report the Health Connect provider as
SDK_AVAILABLE yet fail to bind to the underlying service, so
getGrantedPermissions() throws RemoteException("Binding to service failed").
hasAllPermissions() ran this call without any error handling, and since it is
launched from a LifecycleResumeEffect coroutine the exception propagated
uncaught and crashed the app.

Catch the failure (matching the existing pattern in readNewWorkouts/aggregate)
and treat it as "not granted" so the workout carousel quietly stays in its
prompt state instead of crashing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmuJDzzin15Nfsn1jUYkHh
2026-06-19 21:05:37 +00:00
Claude e5cb5439ff fix: dedupe emoji pack addresses in My Emoji List grid
A kind 10030 emoji selection event can carry the same `a` tag more than
once. MyEmojiListScreen keys its LazyVerticalGrid items by
address.toValue(), so a duplicate address crashed Compose with
"Key ... was already used". Deduplicate on the same value used as the
grid key before rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012fxWguG7dXgwwe2Et3JBjR
2026-06-19 21:01:17 +00:00
Claude a9439d014f fix(notif): always stopSelf when foreground promotion fails
ForegroundServiceDidNotStartInTimeException crashes the whole app when a
service started via startForegroundService() never successfully calls
startForeground() within Android's ~10s window.

NotificationRelayService.initializeForeground() only called stopSelf() on
ForegroundServiceStartNotAllowedException. Any other failure to promote to
the foreground (OEM-specific RemoteException/IllegalStateException, a
resource lookup failure while building the notification, etc.) was logged
but left the service in a "started but not foregrounded" zombie state,
guaranteeing the timeout crash.

Now stopSelf() runs on every failure path, which clears the OS's fgRequired
flag and cancels the pending timeout. onStartCommand also bails early
(START_NOT_STICKY) when foreground promotion failed, so we don't spin up
relay coroutines on a service that's tearing itself down.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hCJDhJmCNkzqb7SaMWQgB
2026-06-19 20:59:27 +00:00
Claude 1b2311e75f feat(cli): add amy nsite fetch to resolve + verify static sites / napplets
Wires the quartz NIP-5A resolver end-to-end so it can be exercised against
real manifests (interop / agents), without building the security-sensitive
WebView shell yet.

- commons BlossomClient: add download(url) — a Blossom GET returning raw bytes
  (null on non-2xx; connection failures propagate so callers try the next
  server). Does not verify the hash; that is the resolver's job.
- cli NsiteCommands: `amy nsite fetch AUTHOR [--d ID] [--path P] [--server …]
  [--relay …] [--out FILE] [--timeout SECS] [--max-inline-bytes N]`. Fetches
  the manifest (kind 15128 root, or 35128 named with --d) from relays, then
  resolves one path through StaticSiteResolver, downloading from the manifest's
  Blossom servers (plus any --server fallbacks) and accepting only the first
  blob whose sha256 matches the manifest pin. Emits the verified path's bytes
  (inlined for small text, or written to --out) with hash/server/content-type,
  or a structured not_found / path_not_found / unresolvable error.

Thin-assembly only: all resolution + verification stays in quartz, the byte
fetch in commons. Smoke-tested offline: bad-args, help, and a dead-relay run
that resolves cleanly to not_found in both text and --json modes.

Also converts the StaticSitePathLookup file-overview KDoc to a plain block
comment to satisfy ktlint no-consecutive-comments.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
2026-06-19 20:46:32 +00:00
Claude b547b07747 feat(quartz): add NIP-5A static-site / napplet resolver
Adds a platform-agnostic resolver for NIP-5A static-website / napplet (NIP-5D)
manifests in quartz commonMain, under nip5aStaticWebsites/resolver/:

- StaticSitePathLookup: request-path normalization (query/fragment stripping,
  leading-slash insensitivity, root/dir -> index.html), slash-insensitive
  path lookup over a manifest's path tags, and a web-asset Content-Type guess.
- StaticSiteResolver: hash verification, Blossom candidate-URL assembly, and a
  suspend resolve() that downloads each listed server in order and accepts the
  first blob whose recomputed sha256 matches the manifest pin. HTTP is injected
  via a BlobFetcher typealias so quartz keeps no HTTP dependency.

The trust model is the point: the signed manifest is the authority, the Blossom
server is untrusted. A server that substitutes/corrupts a blob fails
verification and is skipped -- it can withhold content but never forge it.
Tests cover normalization, lookup, MIME guessing, and the security cases
(tampered server skipped -> falls through to honest server; all-tampered ->
Unresolvable; undeclared path -> PathNotInManifest without fetching).

Also adds quartz/plans/2026-06-19-napplet-nip5a-resolver.md documenting the
design and the open event-shape alignment questions (35128 vs 35129 manifest
kind, capability declaration vs NIP-89, aggregate build hash, server ordering)
to raise with the napplet author before the nsite/napplet event shape forks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CdAJMbnHJfiMY7UcS99T6C
2026-06-19 20:08:27 +00:00
Vitor PamplonaandGitHub a99e67d8be Merge pull request #3284 from vitorpamplona/claude/adoring-pascal-tbm6za
Remove unused `updated` field from translator mappings
2026-06-19 16:05:10 -04:00
Claude 39dc64ff3c fix: stop seeding a fresh timestamp into translators.json every run
The `sinceLastTag.updated` field was set to `date -u` on every
`scripts/translators.sh --seed` run, but nothing ever reads it. The
release-time credit generator only consumes `.mappings` and
`.sinceLastTag.translators`.

Because the field changed on every run, the seed-translators CI job
produced a diff (and therefore a new Crowdin/seed PR) on every push to
main even when the translator set was unchanged. Drop the field from the
seed write and from the committed JSON so the file only changes when a
contributor actually appears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSoN4DDC5F1ehwGeC33652
2026-06-19 20:02:21 +00:00
Vitor Pamplona f247f5a431 updating known mappings for translators and github 2026-06-19 15:53:53 -04:00
Vitor PamplonaandGitHub 1bfdf02884 Merge pull request #3283 from vitorpamplona/claude/friendly-fermat-fj6xxd
Move Cashu token parsing to quartz, unify v3/v4 parsers
2026-06-19 13:55:33 -04:00
Claude b8db7ae6b7 perf(cashu): dispatch token prefixes via DualCase startsWith
Replace startsWith(..., ignoreCase = true) — which case-folds on every
call — with the precomputed DualCase prefixes and the new
String.startsWith(DualCase) helper, so the cashuA/cashuB dispatch only
compares against already-cased strings.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
2026-06-19 17:26:48 +00:00
Vitor PamplonaandGitHub 558370db25 Merge pull request #3281 from vitorpamplona/chore/seed-translators
Seed translator npub placeholders
2026-06-19 13:26:41 -04:00
vitorpamplonaandgithub-actions[bot] 874503a2eb chore: seed translator npub placeholders from Crowdin 2026-06-19 17:21:55 +00:00
Vitor PamplonaandGitHub c37562dc51 Merge pull request #3280 from vitorpamplona/claude/epic-hamilton-m4xors
Add translator credit automation via Crowdin sync
2026-06-19 13:21:34 -04:00
Vitor PamplonaandClaude Opus 4.8 3d26a3d818 v.1.12.5
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:55:37 -04:00
Vitor PamplonaandClaude Opus 4.8 61defaa2e9 fix(ci): resolve the exact Developer ID common name for Compose signing
v1.12.4 got the keychain right ("...in keychain [/Users/.../amethyst-signing
.keychain-db]") but createReleaseDistributable still failed with "Could not
find certificate". Different layer of the same problem:

Compose's MacSigner maps its identity to a cert by running `security
find-certificate -c <identity>`, prepending "Developer ID Application: " when
the identity doesn't already start with it. `codesign --sign` (used by the
signMacJarNatives task, which succeeds in the same job) instead matches a
SHA-1 hash OR any common-name substring. So a MAC_SIGN_IDENTITY secret that is
a fingerprint or a team-ID/partial name signs fine with codesign but, once
prefixed by Compose, is not a substring of the cert's common name -> zero
matches -> failure.

Reproduced locally against the real Developer ID cert:
  find-certificate -c "Developer ID Application: <TEAMID>"      -> 0 matches
  find-certificate -c "Developer ID Application: <full CN>"     -> 1 match

Fix: import-macos-cert now resolves the certificate's full "Developer ID
Application: NAME (TEAMID)" common name from the keychain (via find-identity)
and exposes it as an `identity` output. The desktop build feeds that to
Compose's signing.identity, falling back to the raw secret if resolution
fails. Independent of whatever form the secret takes. The amy CLI leg keeps
using bare codesign with the secret directly and is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 12:54:56 -04:00
Claude 765c1dfd7e feat: generate translator credits offline from the committed file
Makes docs/changelog/translators.json self-sufficient so a release no longer
needs to re-query Crowdin:

- sinceLastTag entries now carry each translator's languages ({user, languages}),
  recorded by the --seed run.
- Default mode (no flags) is offline: it generates the "## Translations" block
  straight from the committed file — reading sinceLastTag, grouping by the stored
  languages, and resolving npubs via the mappings registry. No token, no network.
- --seed/--raw remain the online paths (CI seeding / debugging). curl + git +
  credentials are only required there; the offline path needs just jq.

RELEASE_OPS now points at the tokenless `scripts/translators.sh` for the
changelog credits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 16:02:47 +00:00
Claude ebdf18139b feat(cashu): tolerate both base64 alphabets + redeem multi-mint tokens
- parseCashuA now decodes standard *and* url-safe base64. NUT-00 v3
  specifies base64-urlsafe, but legacy encoders (and older Amethyst
  builds) emitted standard base64; try standard first, fall back to
  url-safe so both round-trip.
- CashuWalletViewModel.redeemToken now redeems every mint/keyset group
  in a pasted token instead of only the first, validating all mints are
  in the wallet up front and summing the redeemed amounts.

Adds parser coverage for both base64 alphabets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
2026-06-19 16:01:22 +00:00
Claude 6b30486667 feat: track a rolling "since last tag" translator list
Reworks docs/changelog/translators.json into two lists maintained by
scripts/translators.sh:

- mappings: a forever-growing Crowdin-username/id -> npub registry. --seed
  appends new contributors with a blank npub and never deletes or overwrites
  existing entries.
- sinceLastTag: a rolling snapshot of who has translated since the last v* tag,
  fully refreshed on every --seed run.

The contribution window now defaults to the most recent v* tag instead of a
fixed two months (falling back to two months ago when no tag is reachable). The
CI seed job fetches tags (fetch-depth: 0) so it can resolve that window.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 15:52:27 +00:00
Claude 3515afa34c fix(cashu): consistent prefix casing + reuse one CBOR codec
Audit follow-ups on the cashu token codec move:

- CashuTokenB64Parser.parse() dispatches on the prefix case-insensitively
  (matching commons RichTextParser's case-insensitive cashuA/cashuB
  detection), but parseCashuA/parseCashuB stripped it with a case-sensitive
  removePrefix — so a mixed-case prefix passed dispatch and then fed its
  own prefix bytes into the Base64 decoder, failing to parse. Strip the
  fixed 6-char prefix with drop() so dispatch and stripping agree.
- hoist a single shared CashuV4Cbor instance instead of allocating a new
  Cbor on every encode (V4Encoder) and every cashuB parse.

Adds an acceptsMixedCasePrefix regression test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
2026-06-19 15:47:09 +00:00
Claude 6c9ca11602 ci: seed translators.json from Crowdin on every sync
Adds a seed-translators job to the Crowdin workflow that runs
scripts/translators.sh --seed (past two months) and opens/updates a single
PR via peter-evans/create-pull-request whenever a new contributor appears, so
docs/changelog/translators.json stays current without manual upkeep. The
action is MIT and CI-only (not linked into any shipped artifact), and no-ops
when there is no diff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 15:46:33 +00:00
Claude fc816f2afd feat: add --seed mode to pre-load translators.json from Crowdin
scripts/translators.sh --seed fetches every contributor in the window
(default: past two months) and merges their Crowdin usernames into
docs/changelog/translators.json with blank npubs, preserving existing
entries and deduping case-insensitively. Fill in the npubs afterwards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 15:42:39 +00:00
Claude a5bf4451ed refactor: move translator-credits script to scripts/, document in RELEASE_OPS
Moves the Crowdin translator-credits generator from tools/translators/ to
scripts/translators.sh to sit with the other flat shell scripts. Drops the
standalone README (the script is self-documenting via --help) and folds the
release-time usage into RELEASE_OPS.md next to the changelog step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 15:41:02 +00:00
Claude 41c686ebb3 feat: generate changelog translator credits from Crowdin
Adds tools/translators/translators.sh, which pulls a Crowdin "Top Members"
report for a release window (between two tags/dates) and prints the changelog
"## Translations" block grouped by language.

Crowdin contributors are joined against docs/changelog/translators.json, a
Crowdin-username/id -> npub mapping kept alongside the changelogs. Contributors
with no mapping are listed under UNMAPPED so they can be credited by hand and
backfilled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FWQWVdWLAwBUBX2gJ55y6b
2026-06-19 15:34:05 +00:00
Claude c5b1d0051a refactor(cashu): move out-of-band token parser + model to quartz
Follow-up to the V4Encoder move. Quartz could encode a cashuB string but
could not parse cashuA/cashuB back, and the parsing lived in amethyst even
though it is pure NUT-00 wire-format protocol. Worse, commons.RichTextParser
already detects cashuA/cashuB words while the parser sat up in the app, so
Desktop (its own rich-text viewer) could not parse a received token at all.

Consolidate the legacy out-of-band redeem stack onto quartz:

- new quartz CashuTokenB64Parser parses cashuA (standard-Base64 JSON, rewritten
  off Jackson onto kotlinx.serialization to satisfy quartz's no-Jackson rule)
  and cashuB (Base64URL CBOR, reusing the V4Token models), returning quartz
  types. It is the inverse of V4Encoder.
- move the CashuToken container model from commons to quartz, switching its
  proofs from the duplicate commons Proof (field C, amount Int) onto the
  canonical quartz CashuProof (field c, amount Long). The duplicate Proof
  type is deleted.
- delete amethyst V3Parser/V3Token/V4Parser; CashuParser/CachedCashuParser
  stay as thin amethyst adapters (off-main-thread guard + GenericLoadable +
  LruCache) over the quartz parser.
- this removes the manual Proof -> CashuProof conversion shims that
  MeltProcessor and CashuWalletViewModel previously carried.

Tests: full cashuA + cashuB vector coverage moves to quartz commonTest
(CashuTokenB64ParserTest, runs on JVM) plus an encode/parse round-trip; the
superseded amethyst CashuV4ParserTest is removed and CashuBTest stays as the
adapter integration test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
2026-06-19 15:32:48 +00:00
Claude 6a65680e61 refactor(cashu): move v4 (cashuB) token codec to quartz
The NUT-00 v4 `cashuB` wire-format encoder + CBOR models lived in
`amethyst/service/cashu/v4`, even though they are pure protocol code with
no app/UI dependencies. quartz had the proof/mint primitives but no
`cashuB` string codec at all, so this was a layering gap rather than a
duplicate.

Move `V4Encoder` and the `V4Token`/`V4T`/`V4Proof`/`V4DleqProof` wire
models into `quartz/nip60Cashu/token`, rewriting the JVM-only
`java.util.Base64` to the multiplatform `kotlin.io.encoding.Base64.UrlSafe`
so the codec lives in commonMain. This unblocks sharing CashuWalletOps /
CashuWalletState into commons (V4Encoder was one of the Android-only ties).

- add kotlinx-serialization-cbor (Apache-2.0) to quartz commonMain
- repoint CashuWalletOps (encoder) and V4Parser (V4Token) imports to quartz
- add V4EncoderTest round-trip coverage in quartz commonTest

amethyst's V4Parser stays put: it returns UI types (GenericLoadable +
commons CashuToken) and now just reuses the relocated quartz V4Token.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013UMNKix4qEfiAPP9s2a4gB
2026-06-19 14:48:40 +00:00
1728 changed files with 274850 additions and 20101 deletions
+46 -2
View File
@@ -13,7 +13,10 @@ a non-interactive JVM command-line client that drives the same `quartz` + `commo
humans, agents, and interop tests. `quic` is a from-scratch pure-Kotlin QUIC v1 + HTTP/3 +
WebTransport client (no JNI, no BouncyCastle), built because no Android-compatible Java QUIC library
exists. `geode` is a standalone JVM Nostr relay (Ktor) built on quartz's
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks) and
relay-server code; smaller modules are `benchmark` (Android macrobenchmarks),
`relayBench` (head-to-head relay benchmark — boots geode, strfry and other
relay binaries, replays a shared deterministic corpus, measures ingest/query/
NIP-77 sync; `./relayBench/run.sh`, see `relayBench/README.md`) and
`quic-interop` (QUIC interop runner, lives at `quic/interop`). `nestsClient` runs
the audio-room protocol on top of `:quic` for the NIP-53 audio-rooms feature. It implements both IETF `draft-ietf-moq-transport-17` (under
`moq/`) and **moq-lite Lite-03** (kixelated's variant, under `moq/lite/`); the
@@ -76,12 +79,44 @@ amethyst/
- `nestsClient/` = MoQ + audio-rooms client; takes `:quic` as transport,
Quartz for crypto, `MediaCodec` / `AudioRecord` / `AudioTrack` for audio.
- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic allowed)
- `cli/` = Thin assembly layer over `quartz/` + `commons/` (no new logic
allowed). May also depend on `:geode` (for `amy serve`, which embeds the
standalone relay); never on `:amethyst` or `:desktopApp`.
**Plans per module:** design docs for new subsystems live in the owning
module's `plans/YYYY-MM-DD-<slug>.md` (e.g. `cli/plans/`, `commons/plans/`).
The global `docs/plans/` folder is frozen — don't add new plans there.
## Android Runtime Processes (IMPORTANT — the app runs in TWO processes)
The Android app is **not single-process**. Android instantiates the one `Amethyst`
Application class (there is no per-process Application in the manifest) in **both**:
- **main** — the normal app: UI, account, signer, `LocalCache`, relay client.
`Amethyst.instance` (`AppModules`) is built here.
- **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
(`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no**
account or keys; `Amethyst.onCreate()` early-returns here so `Amethyst.instance`
is **left unset** (touching it throws `UninitializedPropertyAccessException`).
The sandbox runtime lives in its own module **`:nappletHost`** (depends only on
`:commons` + `:quartz`, **never** `:amethyst`) so it *cannot* import
`Amethyst`/`LocalCache`/`Account` — the broker-side (signer, gateways, registry)
stays in `:amethyst` and the two halves talk over Messenger IPC.
Consequences — don't get caught assuming one process:
- **Processes don't share memory.** Every `object`/companion/`static` is a
*separate copy per process*: `LocalCache` (an `object`), `NappletLaunchRegistry`,
etc. The populated `LocalCache` lives only in **main**; the sandbox neither
builds nor should reference it (a stray reference would lazily create a second,
empty cache there).
- **Don't assume `Amethyst.instance` exists.** Any code reachable from `:napplet`
(the host activity, content server, or an Application lifecycle callback like
`onTrimMemory`) must guard on the process and never reach for `instance`.
- **Cross-process state goes over Messenger IPC**, never a shared singleton — this
is why the broker (main) owns `NappletLaunchRegistry` and the sandbox only relays
an opaque token. See `amethyst/plans/2026-06-22-napplet-nsite-security.md`.
## Tech Stack
Exact versions live in `gradle/libs.versions.toml` (the source of truth — check
@@ -245,3 +280,12 @@ Do this before considering the task complete.
- Commits: Conventional commits (`feat:`, `fix:`, etc.)
- Never use `--no-verify`
### Remotes & pull requests
A PR can be published two ways, via two **kinds** of remote — identify them by **URL** (`git remote -v`), because the names vary per clone and **a collaborator may have only one**:
- a **GitHub** remote (`github.com/vitorpamplona/amethyst`) — the **canonical** `main`; moves constantly. Standard `gh` PR flow.
- a **git-over-nostr** remote (`nostr://…/relay.ngit.dev/amethyst`, via `ngit`) — pushing fans out to GitHub **and** the GRASP git servers; **PRs here are nostr proposals** (the `pr/feat/*` branches), reviewed on **gitworkshop.dev***not* GitHub PRs. (In the maintainer's checkout these happen to be named `upstream` and `origin` respectively, but don't rely on that.)
Before opening, revising, or merging a PR by **either** path, use the **`ngit-pr`** skill. It covers when to use which, identifying your remotes by URL, the `gh` and `ngit` commands, and — critically for the nostr path — the three-mains alignment gate (GitHub main vs the lagging nostr `main` vs local `main`) that its create/revise/merge flows depend on. Skipping it leads to rejected pushes and PRs that don't show up as revisions.
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# PreToolUse gate: make sure Kotlin is spotless-clean BEFORE it leaves the box.
#
# Fires on `git push` (Bash tool) and on the create_pull_request MCP tool. Runs
# `spotlessApply`; if that reformats any tracked .kt/.kts file, the push/PR is
# blocked (exit 2) so the agent commits the formatting fix first. This turns
# CI's `spotlessCheck` failure into an in-session block — no red PR, no round
# trip. `spotlessApply` runs the same formatters CI's `spotlessCheck` verifies,
# so a clean apply means a green check.
set -uo pipefail
cd "${CLAUDE_PROJECT_DIR:-.}" || exit 0
# --- Parse the tool call off stdin; decide whether this call is a boundary. ---
payload="$(cat)"
should_gate="$(
printf '%s' "$payload" | python3 -c '
import json, shlex, sys
try:
data = json.load(sys.stdin)
except Exception:
print("no"); sys.exit(0)
tool = data.get("tool_name", "")
if tool.endswith("create_pull_request"):
print("yes"); sys.exit(0)
if tool != "Bash":
print("no"); sys.exit(0)
cmd = (data.get("tool_input") or {}).get("command", "")
# Tokenize like a shell so `push` inside a quoted commit message or heredoc
# stays one token and is NOT mistaken for the push subcommand.
try:
tokens = shlex.split(cmd, comments=True)
except ValueError:
tokens = cmd.split()
GLOBAL_WITH_ARG = {"-c", "-C", "--namespace", "--git-dir", "--work-tree", "--exec-path"}
for i, t in enumerate(tokens):
if t != "git" and not t.endswith("/git"):
continue
j = i + 1
while j < len(tokens): # skip git global options to reach the subcommand
tok = tokens[j]
if tok in GLOBAL_WITH_ARG:
j += 2; continue
if tok.startswith("-"):
j += 1; continue
break
if j < len(tokens) and tokens[j] == "push":
print("yes"); sys.exit(0)
print("no")
' 2>/dev/null
)"
[ "$should_gate" = "yes" ] || exit 0
# Nothing to format if no Kotlin is tracked/changed at all — cheap early out.
if ! git ls-files --error-unmatch '*.kt' '*.kts' >/dev/null 2>&1; then
exit 0
fi
# Snapshot Kotlin state (vs HEAD, so staged + unstaged both count) before/after
# formatting; any delta means the committed tree wasn't spotless.
before="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
log="$(mktemp /tmp/spotless-gate.XXXXXX.log)"
if ! ./gradlew spotlessApply >"$log" 2>&1; then
# Distinguish a formatting failure (block) from Gradle being unable to RUN —
# e.g. deps can't resolve in a restricted sandbox. An infra failure must not
# strand the agent; warn and let CI's spotlessCheck be the backstop.
if grep -qiE "could not resolve|could not (get|download)|handshake|connect timed out|no address|unable to (find|resolve) host|read timed out" "$log"; then
echo "WARN: could not run spotlessApply (Gradle infra/network failure), skipping the formatting gate." >&2
echo " CI's spotlessCheck still enforces formatting on the PR." >&2
rm -f "$log"
exit 0
fi
echo "BLOCKED: spotlessApply failed — fix the build/formatting error before pushing." >&2
echo "----- gradle output (tail) -----" >&2
tail -n 40 "$log" >&2
rm -f "$log"
exit 2
fi
rm -f "$log"
after="$(git diff HEAD -- '*.kt' '*.kts' 2>/dev/null | sha1sum)"
if [ "$before" != "$after" ]; then
echo "BLOCKED: spotlessApply reformatted Kotlin files that were about to be pushed." >&2
echo "The changes below are now in your working tree. Commit them, then retry:" >&2
echo >&2
git diff --name-only HEAD -- '*.kt' '*.kts' >&2
echo >&2
echo " git add -A && git commit -m 'style: apply spotless' && <retry the push>" >&2
echo "(CI runs 'spotlessCheck'; pushing now would fail the lint job.)" >&2
exit 2
fi
exit 0
+12
View File
@@ -1,5 +1,17 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|mcp__github__create_pull_request",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/.claude/hooks/pre-push-spotless.sh",
"timeout": 180
}
]
}
],
"SessionStart": [
{
"hooks": [
+23 -5
View File
@@ -129,10 +129,12 @@ via `Output.emit`. The template is in `references/command-template.md`;
copy it rather than re-deriving it.
Wire-up checklist:
1. New file in `cli/commands/` with the `object` pattern.
2. Add a branch in `Commands.kt`.
3. Add a branch in `Main.kt`'s `dispatch` (or under `marmotDispatch`
/ a new group dispatcher).
1. New file in `cli/commands/` with the `object` pattern. Sub-verb
`dispatch` functions use the shared `route(...)` helper in
`Router.kt` rather than a hand-rolled `when (tail[0])`.
2. Add a branch in `Main.kt`'s `dispatch` (top-level verbs call the
command object directly, e.g. `"relay" -> RelayCommands.dispatch(…)`;
`marmot` sub-verbs go through `marmotDispatch`'s `route` map).
4. Extend `printUsage()` in `Main.kt`.
5. Add the row to `cli/README.md`'s command table.
6. Update `cli/ROADMAP.md` — move the row from 🆕 / 📦 to ✅.
@@ -173,6 +175,7 @@ cli/
├── secrets/ # SecretStore backends (keychain / ncryptsec / plaintext)
└── commands/ # one file (or group) per top-level verb
├── UseCommand.kt # `amy use NAME`
├── Router.kt # `route(...)` shared sub-verb dispatcher
├── InitCommands.kt # init, whoami
├── CreateCommand.kt + LoginCommand.kt
├── RelayCommands.kt
@@ -186,15 +189,30 @@ cli/
├── MessageCommands.kt
├── MarmotResetCommand.kt
├── AwaitCommands.kt
── StoreCommands.kt
── StoreCommands.kt
├── AdminCommand.kt # `amy admin RELAY METHOD` (NIP-86)
├── ServeCommand.kt # `amy serve` (embeds :geode)
└── cashu/ # `amy cashu …` (NIP-60/61) — thin wrappers
├── CashuCommands.kt # over commons CashuWalletOps / CashuWalletReader
├── CashuWalletCommands.kt + CashuBalanceCommand.kt + CashuMintCommands.kt
└── CashuReceiveCommands.kt + CashuSendCommands.kt
+ CashuMaintenanceCommands.kt + CashuMintRecCommands.kt
```
Shared logic consumed by Amy lives in `commons/`:
- `commons/account/` — account bootstrap
- `commons/marmot/` — MLS / group state
- `commons/cashu/``ops/CashuWalletOps` (jvmAndroid) + `CashuWalletReader`
+ `CashuKeysetCounterStore`; the NIP-60/61 wallet, shared with Android.
- `commons/relayManagement/Nip86Retriever` — NIP-86 HTTP client, shared with
the Android relay-management screen.
- `commons/defaults/` — default relays, kinds
- Consult `commons/plans/` for cross-cutting design work in flight.
A few amy verbs lean on modules beyond `quartz`/`commons`: `amy serve`
depends on `:geode` (the standalone relay) — the one allowed extra module
dependency. `:amethyst` / `:desktopApp` remain forbidden (Rule 5).
## Common mistakes to refuse
- **Adding protocol logic to `cli/`.** Push back, offer to extract.
@@ -18,8 +18,7 @@ object NotePublishCommand {
val args = Args(rest)
val text = args.positional(0, "text")
val ctx = Context.open(dataDir)
try {
Context.open(dataDir).use { ctx ->
ctx.prepare()
val event = com.vitorpamplona.amethyst.commons.note
@@ -33,13 +32,15 @@ object NotePublishCommand {
"rejected_by" to ack.filterValues { !it }.keys.map { it.url },
))
return 0
} finally {
ctx.close()
}
}
}
```
`Context` is `AutoCloseable`; wrap it in `use { }` so it's closed
(RunState flushed, relays disconnected) on every exit path — never a
hand-rolled `try { } finally { ctx.close() }`.
`Output.emit(...)` handles the text-vs-JSON mode automatically. The
result map IS the `--json` shape; the human-readable text default is
derived from the same map by `Output.kt`'s renderer.
@@ -51,39 +52,34 @@ When a feature has several verbs (`note publish`, `note show`,
```kotlin
object NoteCommands {
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int {
if (tail.isEmpty()) return Output.error("bad_args", "note <publish|show|react>")
val rest = tail.drop(1).toTypedArray()
return when (tail[0]) {
"publish" -> NotePublishCommand.run(dataDir, rest)
"show" -> NoteShowCommand.run(dataDir, rest)
"react" -> NoteReactCommand.run(dataDir, rest)
else -> Output.error("bad_args", "note ${tail[0]}")
}
}
suspend fun dispatch(dataDir: DataDir, tail: Array<String>): Int =
route("note", tail, "note <publish|show|react>", mapOf(
"publish" to { rest -> NotePublishCommand.run(dataDir, rest) },
"show" to { rest -> NoteShowCommand.run(dataDir, rest) },
"react" to { rest -> NoteReactCommand.run(dataDir, rest) },
))
}
```
Each verb gets its own file. Once a single file crosses ~200 lines,
split it — see `GroupCommands.kt` and its siblings as the reference.
The shared `route(name, tail, usage, routes)` helper (`Router.kt`)
handles the empty-input and unknown-verb `bad_args` branches, so the
`dispatch` body is just the verb→handler map. Each verb gets its own
file. Once a single file crosses ~200 lines, split it — see
`GroupCommands.kt` and its siblings as the reference.
## Wire-up checklist
For every new command:
1. File under `cli/commands/`.
2. Branch in `Commands.kt`:
2. Branch in `Main.kt`'s top-level `dispatch`, calling the command
object directly:
```kotlin
suspend fun note(dataDir: DataDir, tail: Array<String>): Int =
NoteCommands.dispatch(dataDir, tail)
"note" -> NoteCommands.dispatch(dataDir, tail)
```
3. Branch in `Main.kt`'s top-level `dispatch`:
```kotlin
"note" -> Commands.note(dataDir, tail)
```
4. Line in `printUsage()` explaining the verb.
5. Row in `cli/README.md`'s command table.
6. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
3. Line in `printUsage()` explaining the verb.
4. Row in `cli/README.md`'s command table.
5. Status flip in `cli/ROADMAP.md` (🆕 / 📦 → ✅).
## What not to do
@@ -95,7 +91,7 @@ For every new command:
them to `error: …` (text mode) / `{"error":…}` (JSON mode) plus the
right exit code.
- No holding a connection open across invocations — every run opens
a fresh `Context` and closes it in `finally`.
a fresh `Context` inside `use { }` so it closes on every exit path.
- No blocking reads for user input — take a flag.
- No global flags that collide with subcommand flags. `--name` is
reserved for subcommand use (group/profile name); the global
@@ -38,11 +38,17 @@ The default set of locales (unless the user specifies otherwise):
| Locale | Language | Directory |
|--------|----------|-----------|
| `cs-rCZ` | Czech | `values-cs-rCZ` |
| `cs` | Czech | `values-cs` |
| `pt-rBR` | Brazilian Portuguese | `values-pt-rBR` |
| `sv-rSE` | Swedish | `values-sv-rSE` |
| `de-rDE` | German | `values-de-rDE` |
> Czech was consolidated onto the base qualifier (PR #3461, 2026-07-03): a
> `cs: cs` `languages_mapping` entry in `crowdin.yml` makes Crowdin export to
> `values-cs`, and `values-cs-rCZ` no longer exists. The other locales still
> use Crowdin's default region-qualified `androidCode` until they are
> consolidated the same way — update this table as each one moves.
## Technique
### 1. Identify files
@@ -52,9 +58,9 @@ Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
```
### 2. Find missing keys using cs-rCZ as reference
### 2. Find missing keys using cs as reference
Always diff against `cs-rCZ` first — it is the most complete locale and serves as the reference. Any keys missing in `cs-rCZ` will also be missing in the other target locales.
Always diff against `cs` first — it is the most complete locale and serves as the reference. Any keys missing in `cs` will also be missing in the other target locales.
You MUST diff **both** `<string name=` AND `<plurals name=` — these are independent resource types and a key that is a `<plurals>` in the source will never appear in a `<string>` diff. Forgetting `<plurals>` is the most common silent failure of this skill (it misses things like `music_playlist_track_count`, `notification_count_more`, etc.).
@@ -65,7 +71,7 @@ comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
# Plurals: a separate resource type — MUST be diffed independently
@@ -73,16 +79,16 @@ echo "=== missing <plurals> ==="
comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort)
```
This gives two lists of missing key names — keep them separate; `<plurals>` translations need the per-locale CLDR category set (see Step 5 → "Plurals: handle with care").
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs-rCZ is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs-rCZ set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
Crowdin can asymmetrically strip keys across locales (each translator independently chose source-identical for different keys), so **cs is not a reliable upper bound**. Diff **every** target locale and union the results — don't assume the cs set covers the others. A quick per-locale count is a useful sanity check against the Crowdin UI's "N untranslated":
```bash
for locale in cs-rCZ de-rDE sv-rSE pt-rBR; do
for locale in cs de-rDE sv-rSE pt-rBR; do
ns=$(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
@@ -111,7 +117,7 @@ done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# Missing <plurals>: extract the multi-line block (opening tag through </plurals>)
@@ -124,7 +130,7 @@ while IFS= read -r key; do
done < <(comm -23 \
<(grep '<plurals name=' amethyst/src/main/res/values/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<plurals name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<plurals name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
@@ -199,7 +205,7 @@ done < <(comm -23 \
<(grep '<string name=' amethyst/src/main/res/values/strings.xml \
| grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' amethyst/src/main/res/values-cs-rCZ/strings.xml \
<(grep '<string name=' amethyst/src/main/res/values-cs/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
```
@@ -261,7 +267,7 @@ When adding translated strings to locale files:
- **Diffing only `<string name=`** — `<plurals>` is a separate resource type; a source `<plurals>` missing from a locale will never show up in a `<string>` diff. Always run the diff twice (once per resource type) as shown in Step 2. The same goes for `<string-array>` if the project uses it.
- **Trusting a git "sync-timestamp" heuristic to pre-filter the list** — this skill used to skip keys added before the last `New Crowdin translations` commit, on the theory that Crowdin had already "decided" them. It was dropped: a key added shortly before an export that translators hadn't reached yet is genuinely missing, so the heuristic silently dropped real work. Use the raw on-disk diff and reconcile against the Crowdin web UI's untranslated count instead.
- **Adding source-identical fallbacks locally** — they get overwritten on the next Crowdin sync. Android falls back to `values/strings.xml` at runtime anyway, so a key intentionally kept as English already renders correctly. Skip these by inspection (brand terms, loanwords, `v%1$s`-style strings); don't translate them to an identical value.
- **Skipping per-locale diffs when only diffing cs-rCZ** — Crowdin can strip different keys in different locales (each translator's choice), so cs-rCZ is not a reliable upper bound. Diff each target locale and union the results.
- **Skipping per-locale diffs when only diffing cs** — Crowdin can strip different keys in different locales (each translator's choice), so cs is not a reliable upper bound. Diff each target locale and union the results.
- **Inserting strings in a specific position** — always append at the bottom; ordering is handled separately
- **Hardcoding `"1"` in a `<plurals>` `quantity="one"` item** — always use the count placeholder; otherwise non-English `one` categories produce wrong text
- **Copying English's `one`/`other` set into every locale** — each language must include all CLDR plural categories it uses (e.g. Czech needs `one`, `few`, `many`, `other`)
+183
View File
@@ -0,0 +1,183 @@
---
name: ngit-pr
description: How to create, review, revise, and merge pull requests in this repo, which can be published TWO ways — GitHub (the `gh` CLI) and git-over-nostr (the `ngit` CLI, where PRs are nostr **proposals** reviewed on gitworkshop.dev). Use whenever a task involves opening/updating/merging a PR by either mechanism, the `pr/feat/*` branches, the `ngit` or `gh` CLIs, gitworkshop.dev, or a `nostr://` remote. Remote names vary per clone (and a collaborator may have only one) — this skill identifies remotes by URL, and covers the three-mains alignment gate the nostr flow depends on.
---
# Pull requests: GitHub **and** git-over-nostr
This repo can be contributed to **two** ways, via **two kinds** of remote. Both end up in GitHub `main`.
| Remote kind | URL pattern | Role |
|--|--|--|
| **GitHub** | `github.com/vitorpamplona/amethyst` | **Canonical** `main`. Moves constantly (bots merge often) — a moving target. |
| **git-over-nostr** | `nostr://…/relay.ngit.dev/amethyst` | `ngit`. A push fans out to GitHub **and** the GRASP git servers and publishes nostr events. PRs are **proposals**, reviewed on **gitworkshop.dev**. |
## Step 0 — identify YOUR remotes (names are not universal)
Remote **names are per-clone**. In the maintainer's checkout the GitHub remote is `upstream` and the nostr remote is `origin`, but yours may differ, and **you may have only one of them** (e.g. cloned straight from `nostr://…`, so the nostr remote is your `origin` and there is no separate GitHub remote — pushing it still reaches GitHub via fan-out). Detect by **URL**, never assume a name:
```bash
git remote -v
GH_REMOTE=$(git remote -v | awk '/github\.com/ {print $1; exit}') # GitHub remote (may be empty)
NOSTR_REMOTE=$(git remote -v | awk '/nostr:\/\// {print $1; exit}') # git-over-nostr remote (may be empty)
echo "github=$GH_REMOTE nostr=$NOSTR_REMOTE"
```
The examples below use `$GH_REMOTE` / `$NOSTR_REMOTE` — substitute whichever you have.
## Which path?
| | **GitHub path** (`gh`) | **nostr path** (`ngit`) |
|--|--|--|
| Needs | a GitHub remote + `gh auth status` | a `nostr://` remote + `ngit` ≥ 2.5.0 |
| PR lives on | GitHub only | nostr + GitHub + GRASP (fans out) |
| Use when | Default; PR only needs to be on GitHub. Simplest, no alignment gate. | The PR must be visible/reviewable over nostr (gitworkshop), or you're revising/merging an existing **proposal** (a `pr/feat/*`). |
**Default to GitHub** unless the task is specifically about a nostr proposal (e.g. "the PRs on origin", a gitworkshop link, a `pr/feat/*` branch). If you only have one remote, that decides the path for you. Revise/merge a PR on **the same path it was created** — don't revise a GitHub PR via ngit or vice-versa.
---
# GitHub path (`gh`)
The normal flow most of this repo's history uses ("Merge pull request #NNNN …"). Requires a GitHub remote (`$GH_REMOTE`) and `gh auth status` OK.
```bash
# create — branch off main, push, open the PR
git checkout -b feat/<slug> main
git push -u "$GH_REMOTE" feat/<slug>
gh pr create --repo vitorpamplona/amethyst --base main --head feat/<slug> \
--title "feat: …" --body "…"
# review / list
gh pr list --repo vitorpamplona/amethyst
gh pr view <number> --repo vitorpamplona/amethyst # --comments for the thread
# revise — push more commits to the same branch
git push "$GH_REMOTE" feat/<slug>
# merge (maintainer)
gh pr merge <number> --repo vitorpamplona/amethyst --merge # or --squash
```
GitHub is the source of truth for this path — no three-mains gate. Standard Git Workflow rules from CLAUDE.md still apply (conventional commits, never `--no-verify`).
---
# nostr path (`ngit`)
Requires a `nostr://` remote (`$NOSTR_REMOTE`) and `ngit` ≥ 2.5.0 (`ngit --version`).
**Mental model:** an ngit PR ("proposal") is a *linear patch series off `main`*, published as nostr events. A "revision" is a new version of that proposal. Merging applies the series to `main` and publishes a merged-status event. There is **no** GitHub PR number; `ngit pr merge` makes a plain merge commit (amend it to a readable message).
## ⚠ The three-mains alignment gate (the thing that breaks everything)
Up to **three** `main` heads drift apart:
- GitHub main (`$GH_REMOTE/main` if you have it) — newest, moves every few minutes
- nostr main (`$NOSTR_REMOTE/main` tracking ref) — **lags**, often far behind
- local `main`
**Every create/revise/merge requires the proposal's base to equal the nostr `main`, and pushing `main` to the nostr remote requires GitHub's main to be an ancestor of what you push.** When misaligned, ngit **rejects pre-flight and publishes nothing** (safe — nothing half-breaks; realign and retry). Don't `--force` past it.
```bash
git fetch --all
echo "github=$([ -n "$GH_REMOTE" ] && git rev-parse "$GH_REMOTE/main") \
nostr=$(git ls-remote "$NOSTR_REMOTE" -h refs/heads/main | awk '{print $1}') \
local=$(git rev-parse main)"
# all present heads equal → proceed.
# local behind GitHub? git merge --ff-only "$GH_REMOTE/main" (or "$NOSTR_REMOTE/main" if that's all you have)
# nostr behind local? git push "$NOSTR_REMOTE" main (clean fast-forward only)
```
If you have **only** the nostr remote: align local `main` to `$NOSTR_REMOTE/main`; GitHub is handled by fan-out, and any GitHub/GRASP disagreement surfaces as an ngit rejection on push. If GitHub diverged from nostr (`out of sync with nostr` on push), that's a maintainer `ngit sync --ref-name refs/heads/main --force` situation — **stop and ask the human**, don't run a forced sync unprompted.
## Pushes are slow — run them in the background
`git push "$NOSTR_REMOTE" …`, `ngit send`, and `ngit pr merge` fan out to relays + GRASP servers and **routinely exceed 2 minutes**. Run with `run_in_background: true` and poll (e.g. `git ls-remote "$NOSTR_REMOTE"` for the expected ref). A foreground call hits the 2-minute tool timeout even while the push is actually succeeding.
## Identity
`ngit account whoami` shows the signing key; `ngit send`/`ngit pr merge` sign with **that** key regardless of original author. The maintainer (`VitorPamplona`, `_@vitorpamplona.com`) revising/merging a contributor's proposal with their own key is expected.
## List / view
```bash
ngit pr list # open + draft
ngit pr list --status open,draft,closed,merged,applied
ngit pr view <FULL-hex-event-id | nevent> # FULL id, not the short prefix
```
In `git branch -r`, proposals show as `<nostr-remote>/pr/feat/<slug>(<short-id>)` — the `(...)` is an ngit annotation; the real ref is `pr/feat/<slug>`. Status `applied` == merged.
## Create
Push a `pr/`-prefixed branch (linear, off current `main`):
```bash
git push -o 'title=My title' -o 'description=line1\n\nline2' -u "$NOSTR_REMOTE" pr/feat/<slug>
```
Advanced (cover letter, labels): `ngit send` — see `ngit send --help`.
## Revise (publish a new version)
The revision must be a **linear series off the current `main`** (a merge commit is the wrong shape).
```bash
# 1. align (gate above), then build the linear series:
git checkout -b <work> main
git cherry-pick <original-pr-tip> # existing PR commits
git cherry-pick <your-new-commits…> # yours on top (or: git rebase main)
# 2. verify it compiles + tests pass; tree == your intended change.
# 3. publish as a new version, linked to the proposal:
ngit send --in-reply-to <proposal-nevent> \
--subject "<keep or update title>" \
--description "<what changed>" \
-d main # SINCE_OR_RANGE "main" → commits in main..HEAD
```
`--in-reply-to` threads it under the same proposal on gitworkshop. **No `--force`** once the base equals the nostr `main`. `proposal builds on a commit N ahead of 'origin/main'` ⇒ gate unmet — realign and re-rebase, don't force. Verify: `git ls-remote "$NOSTR_REMOTE" | grep <short-id>` shows `refs/pr/<full-id>/head` at your new tip.
## Merge into main
The merge is **local**; the merged-status event publishes on the subsequent push.
```bash
# 1. ALIGN (mandatory) — all present mains equal.
git checkout main
ngit pr merge <FULL-hex-event-id> -d # local merge commit; marks proposal "applied"
# --squash for a squash merge
# 2. amend the generic merge message to something readable:
git commit --amend -F - <<'MSG'
Merge PR: <title>
Merges nostr proposal <short-id> into main:
- <commit summaries>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
MSG
# 3. sanity-check, then publish (background — slow):
[ -n "$GH_REMOTE" ] && { git merge-base --is-ancestor "$GH_REMOTE/main" HEAD && echo "clean FF push" || echo "github moved; realign"; }
git push "$NOSTR_REMOTE" main
```
Confirm: `ngit pr list --status applied` shows it `applied`, and (if you have it) `git fetch "$GH_REMOTE"` fast-forwards GitHub's main to your merge.
## Cleanup
Delete throwaway branches pushed to the nostr remote (e.g. a `merge/*` used before switching to the proper flow): `git push "$NOSTR_REMOTE" --delete <branch>` (the per-GRASP "non-existent ref" warnings are idempotent fan-out). Delete the local merged branches ngit creates (`pr/feat/<slug>(...)`) and your work branch.
## Failure modes — quick reference
| Symptom | Cause | Fix |
|---------|-------|-----|
| `proposal builds on a commit N ahead of 'origin/main'` | base ≠ stale nostr `main` | realign `main`, rebase series, resend (no `--force`) |
| `! [remote rejected] main … out of sync with nostr` | GitHub main diverged from nostr | maintainer `ngit sync … --force`**ask the human** |
| push "succeeds" but nothing on gitworkshop | pushed a plain branch, not a proposal | use `pr/`-prefix or `ngit send --in-reply-to` |
| `ngit pr view`/`merge` "failed to parse event id" | used the short prefix | pass the **full** hex id or `nevent` |
| push hangs / times out at 2 min | normal GRASP fan-out latency | run in background; verify via `git ls-remote` |
+74 -1
View File
@@ -1,6 +1,6 @@
---
name: nostr-expert
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent). Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
description: Nostr protocol implementation patterns in Quartz (AmethystMultiplatform's KMP Nostr library). Use when working with: (1) Nostr events (creating, parsing, signing), (2) Event kinds and tags, (3) NIP implementations (80+ NIP packages in quartz/), (4) Event builders and TagArrayBuilder DSL, (5) Nostr cryptography (secp256k1, NIP-44 encryption), (6) Relay communication patterns, (7) Bech32 encoding (npub, nsec, note, nevent), (8) Resolving user input (hex, npub, nprofile, or NIP-05 `name@domain` internet identifiers) to a pubkey. Complements nostr-protocol agent (NIP specs) - this skill provides Quartz codebase patterns and implementation details.
---
# Nostr Protocol Expert (Quartz Implementation)
@@ -15,6 +15,7 @@ Practical patterns for working with Nostr in Quartz, AmethystMultiplatform's KMP
- Finding NIP implementations in quartz/ codebase
- Nostr cryptography (secp256k1 signing, NIP-44 encryption)
- Bech32 encoding/decoding (npub, nsec, note formats)
- Resolving user input (hex / npub / nprofile / NIP-05 `name@domain`) to a pubkey
- Event validation and verification
**For NIP specifications** → Use `nostr-protocol` agent
@@ -345,6 +346,55 @@ object Nip04 {
**Note**: Use NIP-44 (`Nip44`) for new implementations. NIP-04 has security issues.
## Hex Encoding (HexKey ↔ ByteArray)
Pubkeys, event ids and signatures are lower-case hex. Quartz uses the `HexKey`
typealias (`= String`) plus extensions in `nip01Core/core/HexKey.kt`, backed by
the `Hex` object in `utils/Hex.kt`. **Use these — never hand-roll a byte loop or
import a third-party hex codec.**
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
val hex: HexKey = bytes.toHexKey() // ByteArray -> lower-case hex
val back: ByteArray = hex.hexToByteArray() // hex -> ByteArray (throws on odd length)
val safe: ByteArray? = input.hexToByteArrayOrNull() // null on invalid hex
Hex.isHex(input) // valid hex, any length
Hex.isHex64(input) // ~30% faster fast-path for a 32-byte key/id
hex.isValid() // 64 chars + valid hex (pubkey / event-id shape)
Hex.isEqual(hex, bytes) // compare hex to bytes without decoding
```
Constants `PUBKEY_LENGTH` / `EVENT_ID_LENGTH` (both 64) live in `nip01Core.core`.
## Core Utilities (time, random, event id)
Reuse these instead of hand-rolling — each avoids a common mistake:
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
TimeUtils.now() // Unix SECONDS for created_at — not currentTimeMillis()/1000
TimeUtils.oneHourAgo() // relative filter bounds (…Ago / …FromNow); all in seconds
RandomInstance.bytes(32) // secure random (SecureRandom) — for nonces/keys, not kotlin.random.Random
RandomInstance.randomChars() // 16-char subscription id
sha256(bytes) // raw hash primitive
EventHasher.hashId(pubKey, createdAt, kind, tags, content) // canonical event id
EventHasher.hashIdCheck(id, pubKey, createdAt, kind, tags, content) // verify untrusted events
```
`EventHasher` serializes `[0, pubkey, created_at, kind, tags, content]` in the
exact form NIP-01 requires — prefer it over calling `sha256` on your own JSON.
## Bech32 Encoding (NIP-19)
Encoding uses extension functions on `ByteArray` (`nip19Bech32/ByteArrayExt.kt`);
@@ -375,6 +425,25 @@ when (val entity = Nip19Parser.uriToRoute(input)?.entity) {
}
```
## Resolving User Input to a Pubkey (NIP-05 + NIP-19)
**Before writing any `if (isHex) … else if (npub) … else if ("@" in s) fetchWellKnown()` logic, stop — it already exists.** `resolveUserHexOrNull` in `quartz/nip05DnsIdentifiers/` accepts every identifier form a user might type and returns a 64-hex pubkey.
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → HexKey? (null if unrecognized/lookup fails)
val pubkey = resolveUserHexOrNull(userInput, nip05Client)
```
- Tries the **synchronous** hex/bech32 path first (`decodePublicKeyAsHexOrNull`) — only NIP-05-shaped input hits the network.
- `suspend`; re-throws only `CancellationException`. Pass `nip05Client = null` for offline contexts.
- Build the client with `Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttp })` (see `cli/Context.kt`). The OkHttp fetcher already runs on IO and disables redirects per the NIP-05 spec — don't re-implement the `.well-known/nostr.json` fetch or JSON parse.
- Need only hex/bech32 (no network)? Use `decodePublicKeyAsHexOrNull(input)` directly.
- Need to *verify* a claimed identifier maps back to a pubkey? `nip05Client.verify(Nip05Id.parse(id)!!, pubkey)`.
See `references/nip05-identifiers.md` for the full API surface (`Nip05Id`, `Nip05Client`, `Nip05Parser`, `KeyInfoSet`, Namecoin `.bit`) and the hand-rolled anti-pattern to avoid.
## Event Validation
```kotlin
@@ -503,6 +572,7 @@ Or see `references/nip-catalog.md` for complete catalog.
- **references/event-hierarchy.md** - Event class hierarchy, kind classifications, common types
- **references/tag-patterns.md** - Tag structure, TagArrayBuilder DSL, common tag types, parsing patterns
- **references/nip19-bech32.md** - `Nip19Parser`, `Bech32Util`, `TlvBuilder`, entity types (NPub, NSec, NEvent, NAddress, NProfile, NRelay, NEmbed)
- **references/nip05-identifiers.md** - Resolving any identifier (hex/npub/nprofile/nsec/`name@domain`) to a pubkey via `resolveUserHexOrNull`; `Nip05Client`, `Nip05Id`, `Nip05Parser`, Namecoin `.bit` — and the hand-rolled anti-pattern to avoid
- **references/event-factory.md** - `EventFactory` dispatch pattern and how to register a new kind
- **references/crypto-and-encryption.md** - Event signing/verification, secp256k1 abstraction, NIP-44 encryption, `SharedKeyCache`
- **references/large-cache.md** - `LargeCache<K,V>` expect/actual + `ICacheOperations` functional API
@@ -518,6 +588,9 @@ Or see `references/nip-catalog.md` for complete catalog.
| Verify signature | `event.verify()` | nip01Core/core/ |
| Encrypt (NIP-44) | `Nip44v2.encrypt(...)` | nip44Encryption/ |
| Bech32 encode | `Nip19.npubEncode(...)` | nip19Bech32/ |
| Resolve input → pubkey | `resolveUserHexOrNull(input, nip05Client)` | nip05DnsIdentifiers/ |
| Decode bech32 → pubkey (no net) | `decodePublicKeyAsHexOrNull(input)` | nip19Bech32/ |
| Verify NIP-05 identifier | `nip05Client.verify(Nip05Id.parse(id)!!, hex)` | nip05DnsIdentifiers/ |
| Find NIP | `scripts/nip-lookup.sh <number>` | - |
## Common Event Kinds
@@ -13,7 +13,7 @@ under `experimental/`**. The categorized list below may lag behind —
| 02 | `nip02FollowList/` | ContactListEvent.kt | Follow/contact lists (kind 3) |
| 03 | `nip03Timestamp/` | OpenTimestampsAttestation.kt | Timestamps |
| 04 | `nip04Dm/` | EncryptedDmEvent.kt | Legacy encrypted DMs (deprecated for NIP-17) |
| 05 | `nip05DnsIdentifiers/` | Nip05Verifier.kt | DNS-based verification |
| 05 | `nip05DnsIdentifiers/` | UserHexResolver.kt, Nip05Client.kt | Internet identifiers; `resolveUserHexOrNull` resolves hex/npub/nprofile/`name@domain` → pubkey (see references/nip05-identifiers.md) |
| 06 | `nip06KeyDerivation/` | Mnemonic-related | BIP-39 key derivation |
| 09 | `nip09Deletions/` | DeletionEvent.kt | Event deletion requests (kind 5) |
| 11 | `nip11RelayInfo/` | RelayInformation.kt | Relay metadata |
@@ -0,0 +1,98 @@
# NIP-05: Identifiers → Pubkey Resolution
How Quartz turns anything a human might type — a raw hex pubkey, an `npub`/`nprofile`/`nsec`, or a NIP-05 internet identifier (`alice@domain.tld`) — into a 64-hex Nostr pubkey. **Everything below already exists in `quartz/nip05DnsIdentifiers/`. Do not hand-roll it.**
## TL;DR — the one function you almost always want
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.resolveUserHexOrNull
// hex | npub1… | nprofile1… | nsec1… | name@domain.tld → 64-hex pubkey (or null)
val pubkey: HexKey? = resolveUserHexOrNull(userInput, nip05Client)
```
`resolveUserHexOrNull(input, nip05Client)` (in `UserHexResolver.kt`) is the canonical "accept any identifier form" resolver. It:
- trims input, tries the **synchronous** bech32/hex path first (`decodePublicKeyAsHexOrNull`), so hex/`npub`/`nprofile`/`nsec` never touch the network;
- only issues an HTTPS fetch for genuinely NIP-05-shaped input (`name@domain.tld`), gated by a cheap `looksLikeNip05()` precheck;
- returns `null` on anything unrecognizable or on a failed NIP-05 lookup (network error / no match);
- re-throws **only** `CancellationException`, so it's safe inside structured concurrency.
Pass `nip05Client = null` in pure-offline contexts — NIP-05-shaped inputs then fall through to `null` and no HTTP is attempted.
## ❌ Do not write this (the hand-rolled anti-pattern)
```kotlin
// DON'T. This re-implements resolveUserHexOrNull badly:
// - no nsec support
// - no input validation (accepts IP-literal / malformed domains → spurious fetches)
// - hand-parses JSON instead of using Nip05Parser
// - bespoke httpGet ignores the "MUST NOT follow redirects" rule
// - swallows CancellationException, breaking structured concurrency
fun resolveObserver(input: String): String? {
if (Hex.isHex64(input)) return input.lowercase()
if (input.startsWith("npub1") || input.startsWith("nprofile1")) { /* … */ }
if ("@" in input) return resolveNip05(input) // bespoke well-known fetch
return null
}
```
## ✅ Do this instead
```kotlin
// CLI already exposes it — commands call Context.requireUserHex(input):
val pubHex = resolveUserHexOrNull(input, nip05Client)
?: return Output.error("bad_args", "expected npub, nprofile, 64-hex, or name@domain.tld")
```
## Building an `Nip05Client`
`resolveUserHexOrNull` takes an `INip05Client`. On JVM/Android, wire the OkHttp fetcher (mirror what `cli/Context.kt` does):
```kotlin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
val nip05Client = Nip05Client(fetcher = OkHttpNip05Fetcher { _ -> okHttpClient })
```
`OkHttpNip05Fetcher` already runs on `Dispatchers.IO` and disables redirects per the NIP-05 spec ("Fetchers MUST ignore any HTTP redirects"). Don't re-implement the fetch.
For tests / offline code, `EmptyNip05Client` is a no-op stub.
## The pieces (all in `quartz/…/nip05DnsIdentifiers/`)
| Type | File | Purpose |
|------|------|---------|
| `resolveUserHexOrNull(input, client?)` | `UserHexResolver.kt` | **Start here.** Any identifier form → 64-hex pubkey, or null. `suspend`. |
| `Nip05Id` | `Nip05Id.kt` | Parsed `name@domain`. `Nip05Id.parse(str)` validates (RFC 5321 local-part + hostname rules, rejects IP literals) and lowercases. `toUserUrl()` / `toDomainUrl()` build the `.well-known/nostr.json` URLs. `toDisplayValue()` collapses the `_` wildcard to just the domain. |
| `INip05Client` / `Nip05Client` | `INip05Client.kt`, `Nip05Client.kt` | Async resolver. `get(id): Nip05KeyInfo?` (pubkey + relays), `verify(id, hex): Boolean`, `load(id): KeyInfoSet?`, `list(domain): KeyInfoSet`, `loadClinkOffer(id): String?`. Auto-routes `.bit` domains to Namecoin. `EmptyNip05Client` = offline no-op. |
| `Nip05Fetcher` / `OkHttpNip05Fetcher` | `Nip05Fetcher.kt`, `OkHttpNip05Fetcher.kt` (jvmAndroid) | Transport SAM. OkHttp actual disables redirects + runs on IO. |
| `Nip05Parser` | `Nip05Parser.kt` | JSON `.well-known/nostr.json` codec: `parseHexKey`, `parseHexKeyAndRelays`, `parse``KeyInfoSet`, `parseClinkOffer`. |
| `Nip05KeyInfo` / `KeyInfoSet` | `Nip05KeyInfo.kt`, `KeyInfoSet.kt` | `Nip05KeyInfo(pubkey, relays)`; `KeyInfoSet(names: Map, relays: Map)` = the full domain listing. |
| `NamecoinNameResolver` | `namecoin/NamecoinNameResolver.kt` | `.bit` / `d/…` / `id/…` blockchain identifiers. `isNamecoinIdentifier(str)`, `resolve(str)`. Invoked automatically by `Nip05Client` — you rarely call it directly. |
## When you only need the pure (synchronous, no-network) part
If the input can only be hex/bech32 (no NIP-05), skip the client entirely:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
// hex | npub1… | nprofile1… | nsec1… → 64-hex pubkey (or null). No suspend, no network.
val pubkey: HexKey? = decodePublicKeyAsHexOrNull(input)
```
See `references/nip19-bech32.md` for the full bech32 entity story. `resolveUserHexOrNull` is just this function plus the NIP-05 HTTP fallback.
## Verifying a claimed identifier
To confirm a profile's advertised `nip05` actually points back to its pubkey (NIP-05 verification), use `verify`, not `get`:
```kotlin
val ok: Boolean = nip05Client.verify(Nip05Id.parse("alice@domain.tld")!!, profilePubkeyHex)
```
## Tests
`quartz/src/commonTest/…/nip05DnsIdentifiers/Nip05Test.kt` covers parsing, URL construction, case-normalization, CLINK offers, and the validation rejects (IP literals, malformed domains).
+229 -6
View File
@@ -1,13 +1,13 @@
---
name: quartz-integration
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects.
description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects, (9) running a relay on Quartz and serving/building its NIP-11 relay information document (application/nostr+json).
---
# Quartz Integration Guide
Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects.
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.4` (Maven Central)
**Published artifact**: `com.vitorpamplona.quartz:quartz:1.12.6` (Maven Central)
**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`)
**License**: MIT
@@ -19,7 +19,7 @@ Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr
```toml
[versions]
quartz = "1.12.4"
quartz = "1.12.6"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -41,7 +41,7 @@ kotlin {
```kotlin
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.4")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
}
```
@@ -134,13 +134,14 @@ val privKeyHex: String? = keyPair.privKey?.toHexKey()
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
// ByteArray → hex
val hex = byteArray.toHexKey()
// hex → ByteArray
val bytes = HexKey.decodeHex(hex)
val bytes = hex.hexToByteArray()
// Bech32 import (npub, nsec)
val parsed = Nip19Parser.uriToRoute("npub1abc...")
@@ -148,6 +149,126 @@ val parsed = Nip19Parser.uriToRoute("npub1abc...")
val parsed = Nip19Parser.uriToRoute("nsec1abc...")
```
> Hex ↔ ByteArray is a first-class utility in Quartz — see **§3.1 Hex utilities** below.
---
### 3.1 Hex utilities (HexKey ↔ ByteArray)
Nostr keys, event ids and signatures travel as lower-case hex strings. Quartz
models this with the `HexKey` typealias (just a `String`) plus extension
functions — **do not** write your own byte loop or pull in a third-party codec.
**Packages:** `com.vitorpamplona.quartz.nip01Core.core` (the extensions) and
`com.vitorpamplona.quartz.utils` (the underlying `Hex` object).
```kotlin
import com.vitorpamplona.quartz.nip01Core.core.HexKey // typealias = String
import com.vitorpamplona.quartz.nip01Core.core.toHexKey // ByteArray → hex
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray // hex → ByteArray
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.utils.Hex
// Encode / decode
val hex: HexKey = pubKeyBytes.toHexKey() // lower-case, 2 chars per byte
val bytes: ByteArray = hex.hexToByteArray() // throws on odd length
// Untrusted input → decode safely
val maybe: ByteArray? = userInput.hexToByteArrayOrNull() // null if not valid hex
// Validate without decoding (no allocation)
Hex.isHex(userInput) // even-length, all hex digits (any length)
Hex.isHex64(userInput) // fast path for a 32-byte key/id (checks first 64 chars)
hex.isValid() // 64 chars AND valid hex (pubkey / event-id shape)
// Compare a hex string to raw bytes without decoding
Hex.isEqual(incomingHexId, myIdBytes)
```
| Need | Call | Notes |
|------|------|-------|
| ByteArray → hex | `bytes.toHexKey()` | lower-case output |
| hex → ByteArray (strict) | `hex.hexToByteArray()` | throws on odd length |
| hex → ByteArray (safe) | `hex.hexToByteArrayOrNull()` | `null` on invalid hex |
| is this valid hex? | `Hex.isHex(s)` / `Hex.isHex64(s)` | `isHex64` ~30% faster for keys/ids |
| is this a pubkey/id shape? | `hex.isValid()` | 64 chars + valid hex |
| hex == bytes? | `Hex.isEqual(hex, bytes)` | no decode allocation |
Constants `PUBKEY_LENGTH` and `EVENT_ID_LENGTH` (both `64`) live in the same
`nip01Core.core` package.
---
### 3.2 Everyday utilities (time, random, hashing, bech32, base64)
These small helpers exist so you don't reinvent them — and several have a
footgun the built-in avoids. **Prefer them over stdlib/hand-rolled equivalents.**
**Time — `TimeUtils` (`com.vitorpamplona.quartz.utils`).** Everything is in Unix
**seconds** (what `created_at` and filter `since`/`until` use), *not* millis.
```kotlin
import com.vitorpamplona.quartz.utils.TimeUtils
val createdAt = TimeUtils.now() // seconds — for created_at. NOT currentTimeMillis()/1000
val since = TimeUtils.oneDayAgo() // relative filter bounds: oneHourAgo(), fiveMinutesAgo()…
val fresh = TimeUtils.withinTenMinutes(event.createdAt) // NIP-42/NIP-98 freshness
// TimeUtils.nowMillis() is the only millisecond helper — non-protocol use only.
```
**Secure random — `RandomInstance` (`utils`).** Backed by `SecureRandom`; use it
for anything security-sensitive instead of `kotlin.random.Random`.
```kotlin
import com.vitorpamplona.quartz.utils.RandomInstance
val nonce = RandomInstance.bytes(32) // nonces, salts, keys
val subId = RandomInstance.randomChars() // 16-char [a-zA-Z0-9] subscription id
```
**Hashing — `sha256(...)` + `EventHasher`.** `sha256` is the raw primitive; to
compute/verify an **event id** use `EventHasher`, which canonically serializes
`[0, pubkey, created_at, kind, tags, content]` before hashing (getting this wrong
is what makes relays reject an event). Typed builders already do this for you.
```kotlin
import com.vitorpamplona.quartz.utils.sha256.sha256
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
val digest = sha256(bytes) // raw 32-byte hash
val id = EventHasher.hashId(pubKey, createdAt, kind, tags, content)
val valid = EventHasher.hashIdCheck(event.id, event.pubKey, event.createdAt, event.kind, event.tags, event.content)
```
**Bech32.** For `npub`/`nsec`/`note`/… prefer the NIP-19 layer (`ByteArray.toNpub()`,
`Nip19Parser.uriToRoute(...)` — see §10). Drop to the low-level
`Bech32` object (`nip19Bech32.bech32`) only for a custom prefix:
```kotlin
import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
val addr = Bech32.encodeBytes("npub", pubKeyBytes, Bech32.Encoding.Bech32)
val bytes = "npub1...".bechToBytes("npub") // decode + assert the prefix
```
**Base64.** Quartz has no wrapper — use the Kotlin stdlib `kotlin.io.encoding.Base64`
directly, and match the variant the spec wants: NIP-44/NIP-04 payloads use
`Base64.Default` (standard, padded); url-safe contexts use `Base64.UrlSafe`
(configure padding via `.withPadding(...)`).
| Need | Call |
|------|------|
| Now (event `created_at`) | `TimeUtils.now()` (seconds) |
| Relative filter bound | `TimeUtils.oneDayAgo()` / `oneHourAgo()` / … |
| Secure random bytes | `RandomInstance.bytes(n)` |
| Subscription id | `RandomInstance.randomChars()` |
| Raw hash | `sha256(bytes)` |
| Event id / verify | `EventHasher.hashId(...)` / `hashIdCheck(...)` |
| Bech32 custom prefix | `Bech32.encodeBytes(hrp, bytes, enc)` / `s.bechToBytes(hrp)` |
| Base64 | `kotlin.io.encoding.Base64` (`.Default` / `.UrlSafe`) |
---
## 4. Signing Events
@@ -634,7 +755,100 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
---
## 15. Quick Reference
## 15. NIP-11 Relay Information Document
If you're standing up a relay on Quartz's relay-server code, serve your NIP-11
document with the **type-safe builder** — don't hand-write the JSON string.
**Package:** `com.vitorpamplona.quartz.nip11RelayInfo`
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.nip11RelayInfo.relayInformation
val info =
relayInformation {
name = "sot"
description = "NIP-50 profile search ranked by Nostr web-of-trust"
software = "https://github.com/vitorpamplona/sot"
version = "0.1"
supports(1, 11, 42, 50) // ints → spec-compliant [1,11,42,50] in the JSON
}
val json = info.toJson() // null/empty fields are omitted
```
Serve it at the relay root, branching on the `Accept` header (Ktor example):
```kotlin
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import io.ktor.http.ContentType
get("/") {
val accept = call.request.headers[HttpHeaders.Accept].orEmpty()
if (accept.contains(Nip11RelayInformation.CONTENT_TYPE)) { // "application/nostr+json"
call.respondText(json, ContentType.parse(Nip11RelayInformation.CONTENT_TYPE))
} else {
call.respondText("Open a WebSocket (NIP-01) or send Accept: ${Nip11RelayInformation.CONTENT_TYPE}")
}
}
```
### Nested objects, lists, and enforced limits
```kotlin
val info =
relayInformation {
name = "Paid Relay"
supports(1, 11, 42)
supportsExtensions("nip50-search") // supported_nip_extensions
countries("US", "CA") // relay_countries; also languages(...), tags(...)
nip50Features("profile_search") // the `nip50` field
// limitation { } — camelCase maps to NIP-11 snake_case fields
limitation {
maxSubscriptions = 20
maxFilters = 10
authRequired = true
}
// fees { } — each helper is repeatable
fees {
admission(amount = 1000, unit = "msats")
publication(amount = 100, unit = "msats", kinds = listOf(1, 30023))
}
// retention(...) — call once per policy entry
retention(kinds = listOf(0, 3), count = 1)
}
```
**Keep advertised limits in sync with enforced ones.** If you build a
`RelayLimits` for the server's policy chain, hand the *same* object to the
builder so what you publish can never drift from what you enforce:
```kotlin
import com.vitorpamplona.quartz.nip01Core.relay.server.policies.RelayLimits
val limits = RelayLimits(maxSubscriptions = 20, maxFilters = 10, maxLimit = 500, authRequired = true)
val info =
relayInformation {
name = "My Relay"
supports(1, 11, 42, 45)
limitation(limits) // == limits.toNip11Limitation()
}
```
To load an operator-supplied doc from disk or a string instead of building it,
use `Nip11RelayInformation.fromJson(json)`.
> `geode` (Quartz's standalone relay) builds its default document exactly this
> way — see `geode/.../RelayInfo.kt`.
---
## 16. Quick Reference
| Task | API | Package |
|------|-----|---------|
@@ -644,6 +858,13 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| Sign event | `signer.sign(template)` | `nip01Core.signers` |
| Serialize | `event.toJson()` | `nip01Core.core` |
| Parse | `Event.fromJson(json)` | `nip01Core.core` |
| ByteArray → hex | `bytes.toHexKey()` | `nip01Core.core` |
| hex → ByteArray | `hex.hexToByteArray()` / `hex.hexToByteArrayOrNull()` | `nip01Core.core` |
| Validate hex | `Hex.isHex(s)` / `Hex.isHex64(s)` / `hex.isValid()` | `utils`, `nip01Core.core` |
| Now (seconds) | `TimeUtils.now()` | `utils` |
| Relative time | `TimeUtils.oneDayAgo()` / `oneHourAgo()` | `utils` |
| Secure random | `RandomInstance.bytes(n)` / `randomChars()` | `utils` |
| Hash / event id | `sha256(bytes)` / `EventHasher.hashId(...)` | `utils.sha256`, `nip01Core.crypto` |
| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` |
| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` |
| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` |
@@ -651,6 +872,8 @@ val results = store.query<Event>(Filter(search = "bitcoin"))
| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` |
| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` |
| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` |
| Build NIP-11 doc | `relayInformation { name = ...; supports(1, 11) }` | `nip11RelayInfo` |
| Serialize NIP-11 doc | `info.toJson()` (media type `Nip11RelayInformation.CONTENT_TYPE`) | `nip11RelayInfo` |
## Common Event Kinds
@@ -3,7 +3,7 @@
## Current version
```
com.vitorpamplona.quartz:quartz:1.12.4
com.vitorpamplona.quartz:quartz:1.12.6
```
Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz
@@ -16,7 +16,7 @@ Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/qua
```toml
[versions]
quartz = "1.12.4"
quartz = "1.12.6"
[libraries]
quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" }
@@ -55,7 +55,7 @@ kotlin {
```kotlin
// build.gradle.kts (app module)
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.4")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
}
```
@@ -70,7 +70,7 @@ plugins {
}
dependencies {
implementation("com.vitorpamplona.quartz:quartz:1.12.4")
implementation("com.vitorpamplona.quartz:quartz:1.12.6")
// JNA needed for libsodium (NIP-44) on JVM
implementation("net.java.dev.jna:jna:5.18.1")
}
+4
View File
@@ -0,0 +1,4 @@
# Gzip-compressed test corpora (e.g. nostr_vitor_startup_data.json.gz): treat as
# binary so git never applies CRLF/text normalization or textual diff/merge, which
# would corrupt the compressed stream (important on Windows checkouts).
*.gz binary
@@ -24,6 +24,15 @@ outputs:
Compose's macOS signing, whose certificate lookup doesn't resolve the
search list reliably on CI runners the way bare `codesign` does.
value: ${{ steps.import.outputs.keychain }}
identity:
description: >
The imported certificate's full "Developer ID Application: NAME (TEAMID)"
common name, resolved from the keychain (empty when signing=false or if it
could not be parsed). `codesign` accepts a SHA-1 hash or a CN substring,
but Compose's MacSigner runs `security find-certificate -c <identity>`
after prepending "Developer ID Application: " — so it only works with the
exact common name. Feed this to Compose's `signing.identity`.
value: ${{ steps.import.outputs.identity }}
runs:
using: composite
@@ -62,5 +71,20 @@ runs:
# explicit keychain can read the `keychain` output below.
security default-keychain -d user -s "$KEYCHAIN"
rm -f "$CERT_PATH"
# Resolve the certificate's exact common name. Compose's MacSigner maps
# its identity to a cert via `security find-certificate -c <name>` (after
# prepending "Developer ID Application: " when absent), so it needs the
# full CN — a SHA-1 hash or partial name in the secret signs fine with
# bare `codesign` but yields "Could not find certificate ...". Pull the
# canonical name straight from the keychain so the caller is independent
# of whatever form the MAC_SIGN_IDENTITY secret takes.
IDENTITY_NAME="$(security find-identity -v -p codesigning "$KEYCHAIN" \
| grep -o '"Developer ID Application:[^"]*"' | head -1 | tr -d '"' || true)"
echo "signing=true" >> "$GITHUB_OUTPUT"
echo "keychain=$KEYCHAIN" >> "$GITHUB_OUTPUT"
echo "identity=$IDENTITY_NAME" >> "$GITHUB_OUTPUT"
if [[ -n "$IDENTITY_NAME" ]]; then
echo "::notice::Resolved signing identity: $IDENTITY_NAME"
else
echo "::warning::Could not resolve a 'Developer ID Application' identity from the keychain; callers will fall back to the MAC_SIGN_IDENTITY secret."
fi
+4 -4
View File
@@ -19,7 +19,7 @@ jobs:
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -66,7 +66,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -122,7 +122,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -181,7 +181,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Re-assert stable release
uses: ./.github/actions/assert-stable-release
+56 -9
View File
@@ -50,7 +50,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -119,8 +119,13 @@ jobs:
env:
# Empty on non-macOS legs and on the macOS leg when no cert is
# configured — the gradle macOS{} block skips signing when the
# identity is blank.
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && secrets.MAC_SIGN_IDENTITY || '' }}
# identity is blank. Prefer the full common name resolved from the
# keychain over the raw secret: Compose's signer maps the identity via
# `security find-certificate` and only matches the exact "Developer ID
# Application: …" CN, whereas the secret may be a hash or partial name
# (which bare codesign accepts but Compose does not). Fall back to the
# secret if resolution failed.
AMETHYST_MAC_SIGN_IDENTITY: ${{ steps.mac_keychain.outputs.signing == 'true' && (steps.mac_keychain.outputs.identity || secrets.MAC_SIGN_IDENTITY) || '' }}
# Explicit keychain for Compose's MacSigner. Its `security
# find-certificate` lookup doesn't resolve the imported cert via the
# search list on these runners ("Could not find certificate ... in
@@ -195,7 +200,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -244,7 +249,7 @@ jobs:
shell: bash
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -446,7 +451,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -474,7 +479,7 @@ jobs:
timeout-minutes: 60
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -483,7 +488,7 @@ jobs:
java-version: 21
- name: Cache gradle
uses: actions/cache@v5
uses: actions/cache@v6
with:
path: |
~/.gradle/caches
@@ -567,6 +572,48 @@ jobs:
"dist/amethyst-fdroid-${TAG}.aab"
ls -la dist
# Accrescent does not accept AABs or monolithic APKs — it requires a signed
# APK set (.apks) of split APKs generated by bundletool from the AAB. We build
# it from the F-Droid flavor (no proprietary Google deps) and sign the splits
# with the same release keystore used above. Upload is still manual: drop this
# .apks into https://console.accrescent.app (no publish API/CLI exists yet).
- name: Build Accrescent APK set (F-Droid)
env:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
KEY_STORE_PASSWORD: ${{ secrets.KEY_STORE_PASSWORD }}
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
run: |
set -euo pipefail
TAG="${GITHUB_REF_NAME}"
BUNDLETOOL_VERSION="1.18.3" # must be >= 1.11.4 per Accrescent requirements
curl -fsSL -o bundletool.jar \
"https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar"
# Same base64 keystore secret consumed by the r0adkll signing steps above.
echo "$SIGNING_KEY" | base64 -d > release.keystore
# --mode=default emits the split-APK set Accrescent wants (NOT --mode=universal,
# which produces a monolithic APK that Accrescent rejects).
java -jar bundletool.jar build-apks \
--bundle="dist/amethyst-fdroid-${TAG}.aab" \
--output="dist/amethyst-fdroid-${TAG}.apks" \
--ks=release.keystore \
--ks-key-alias="$KEY_ALIAS" \
--ks-pass="pass:$KEY_STORE_PASSWORD" \
--key-pass="pass:$KEY_PASSWORD" \
--mode=default
rm -f release.keystore bundletool.jar
# Accrescent's automated check rejects an APK set larger than 128 MiB.
SIZE_BYTES=$(stat -c%s "dist/amethyst-fdroid-${TAG}.apks")
echo "Accrescent APK set size: $((SIZE_BYTES / 1024 / 1024)) MiB"
if [ "$SIZE_BYTES" -gt $((128 * 1024 * 1024)) ]; then
echo "::warning::amethyst-fdroid-${TAG}.apks exceeds Accrescent's 128 MiB limit; the console will reject this upload."
fi
ls -la dist
- name: Classify release
id: classify
run: |
@@ -579,7 +626,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@b4309332981a82ec1c5618f44dd2e27cc8bfbfda # v3.0.0
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+46 -7
View File
@@ -10,12 +10,21 @@ permissions:
pull-requests: write
jobs:
# Single job so the Crowdin translation sync and the translator-placeholder seed
# land in ONE pull request instead of two. The Crowdin action only downloads into
# the working tree (push_translations/create_pull_request disabled); the seed
# script then edits translators.json; finally one create-pull-request step opens a
# single PR with both sets of changes (and no-ops when there is no diff).
synchronize-with-crowdin:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
# Need tags so scripts/translators.sh can resolve the last v* release tag
# for the "since last tag" window.
fetch-depth: 0
- name: crowdin action
uses: crowdin/github-action@v2
@@ -23,12 +32,42 @@ jobs:
upload_sources: true
upload_translations: true
download_translations: true
localization_branch_name: l10n_crowdin_translations
create_pull_request: true
pull_request_title: 'New Crowdin Translations'
pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
pull_request_base_branch_name: 'main'
# Let the downloaded translations stay in the working tree; the single
# create-pull-request step below opens the combined PR.
push_translations: false
create_pull_request: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# Keep docs/changelog/translators.json seeded with everyone who has translated
# recently, so the per-release `## Translations` credits (scripts/translators.sh)
# can resolve them to npubs. Only adds rows when a genuinely new contributor
# appears.
- name: Seed translator placeholders from Crowdin
run: bash scripts/translators.sh --seed
env:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
- name: Open or update the combined Crowdin PR
# peter-evans/create-pull-request is MIT-licensed CI-only tooling (not
# linked into any shipped artifact). It no-ops when there is no diff.
uses: peter-evans/create-pull-request@v8
with:
token: ${{ secrets.GITHUB_TOKEN }}
base: main
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'
body: |
New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action).
Any new Crowdin contributors were added to `docs/changelog/translators.json`
with blank npubs. Fill in the npubs you have so the next release's
`## Translations` credits generate automatically via
`scripts/translators.sh --from <prev-tag> --to <this-tag>`.
+2 -2
View File
@@ -25,7 +25,7 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
@@ -55,7 +55,7 @@ jobs:
timeout-minutes: 45
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
+50
View File
@@ -163,6 +163,56 @@ Examples:
---
## Reproducible Android builds
The release APKs are **bit-for-bit reproducible**: anyone can rebuild the exact
bytes we ship (minus the signature) from the tagged source and confirm the
artifact on F-Droid / Zapstore / GitHub was built from this code and nothing
else. What makes that hold:
- **Pinned toolchain.** AGP, Kotlin, R8, and the Compose compiler are pinned in
`gradle/libs.versions.toml`; the build targets **JDK 21**. R8 is deterministic
for a fixed version + inputs, so the minified output is stable. Build with the
same JDK 21 you see in `BUILDING.md` / CI.
- **No build-time clock.** Nothing injects `System.currentTimeMillis()` /
build dates into `BuildConfig` (a Spotless rule bans the call in `quartz` and
`commons`), and AGP normalizes ZIP entry timestamps, so two builds an hour
apart are identical.
- **Deterministic version name.** `generateVersionName` only appends a branch
suffix off feature branches; a release tag builds in detached-`HEAD` (or from a
source tarball with no `.git`) resolve to the bare `app` version.
- **No dependency-metadata blob.** `dependenciesInfo { includeInApk = false;
includeInBundle = false }` in `amethyst/build.gradle.kts` stops AGP from
embedding the Google-encrypted dependency protobuf in the signing block — that
ciphertext is non-deterministic.
- **Reproducible native library.** The bundled Tor (Arti) `.so` is the one
binary we compile ourselves; it is built reproducibly from source (pinned Rust
toolchain, locked deps, canonical build path). See
[`tools/arti-build/README.md`](tools/arti-build/README.md) → "Reproducible
builds". All other native libs (`secp256k1`, `webrtc`) are version-pinned Maven
prebuilts and so are byte-identical by download.
### Verify a release APK reproduces
```bash
# 1. Check out the exact released tag and build the same variant unsigned.
git checkout v1.12.1
./gradlew clean :amethyst:assembleFdroidRelease
# 2. Diff your unsigned build against the published APK, ignoring only the
# signature (META-INF/*). apksigner + a zip-aware diff is the simplest check;
# diffoscope gives a human-readable breakdown of any remaining delta.
diffoscope \
amethyst/build/outputs/apk/fdroid/release/amethyst-fdroid-arm64-v8a-release-unsigned.apk \
amethyst-fdroid-arm64-v8a-1.12.1.apk
```
A clean run shows differences confined to `META-INF/` (the signing files). Any
diff in `classes*.dex`, `resources.arsc`, or native libs means something in the
toolchain drifted — file it before publishing.
---
## Release runbook
The release flow is driven by a tag push. Every cut ships Android + Desktop +
+83
View File
@@ -0,0 +1,83 @@
# Amethyst plans index
_Cross-module roll-up of every `plans/` folder. Surveyed 2026-06-30._
Plans in this repo are **decentralized**: each module keeps its own design docs
in `<module>/plans/YYYY-MM-DD-<slug>.md` (see `.claude/CLAUDE.md`
"Plans per module"). There is no single plans directory — **this file is the
master index** that stitches the per-folder indexes together.
Each plan carries a `Status:` header (shipped | in-progress | queued |
abandoned) backed by codebase evidence. **Shipped** plans are moved into each
folder's `archive/`; live work stays at the top level. For the full per-folder
listing (including archived plans), open that folder's `README.md`.
> `docs/plans/` is the **frozen** legacy global folder — it is indexed here for
> completeness, but new plans must go in the owning module's `plans/` folder.
## Totals
**142 plans** across 10 folders:
| Status | Count |
| ------ | ----: |
| shipped (archived) | 122 |
| in-progress | 9 |
| queued | 8 |
| abandoned | 3 |
## By module
| Module | Plans | Shipped | In-prog | Queued | Aband. | Index |
| ------ | ----: | ------: | ------: | -----: | -----: | ----- |
| amethyst | 21 | 19 | 1 | 1 | 0 | [amethyst/plans](amethyst/plans/README.md) |
| nestsClient | 26 | 23 | 1 | 2 | 0 | [nestsClient/plans](nestsClient/plans/README.md) |
| desktopApp | 13 | 10 | 2 | 1 | 0 | [desktopApp/plans](desktopApp/plans/README.md) |
| quartz | 10 | 7 | 0 | 3 | 0 | [quartz/plans](quartz/plans/README.md) |
| commons | 6 | 2 | 2 | 2 | 0 | [commons/plans](commons/plans/README.md) |
| cli | 6 | 5 | 1 | 0 | 0 | [cli/plans](cli/plans/README.md) |
| quic | 4 | 3 | 0 | 0 | 1 | [quic/plans](quic/plans/README.md) |
| quic/interop | 1 | 1 | 0 | 0 | 0 | [quic/interop/plans](quic/interop/plans/README.md) |
| geode | 5 | 4 | 0 | 0 | 0 | [geode/plans](geode/plans/README.md) |
| docs (frozen) | 52 | 48 | 2 | 0 | 2 | [docs/plans](docs/plans/README.md) |
## Live work (not shipped)
Everything still open, across all modules. Shipped plans are omitted here — find
them under each folder's `archive/` via the per-module index above.
### In progress (9)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| amethyst | [ios-support](amethyst/plans/2026-05-24-ios-support.md) | KMP-to-iOS port; quartz/commons iOS targets configured (Phase 1) but no `iosApp` module yet. |
| commons | [custom-feeds-plan](commons/plans/2026-05-04-custom-feeds-plan.md) | Custom feeds; model + builder + kind 31890 + desktop UI shipped, but relay-filter layer, DVM marketplace, kind 10090 sync, list resolution pending. |
| commons | [nest-subscription-manager-extraction](commons/plans/2026-05-06-nest-subscription-manager-extraction.md) | Split per-speaker subscription state machine out of `NestViewModel`; only the `ActiveSubscription` stepping-stone extracted. |
| desktopApp | [wallet-zapping-test-coverage](desktopApp/plans/2026-05-12-feat-desktop-wallet-zapping-test-coverage-plan.md) | NWC handler + RPC round-trip tests shipped; wallet-column-state and zap-dialog-logic tests still missing. |
| desktopApp | [napplet-desktop-host](desktopApp/plans/2026-06-21-napplet-desktop-host.md) | Desktop NIP-5A/5D host; shared core extractions done, desktop engine/scheme-handler/transport/UI edge not built. |
| cli | [cashu-cli](cli/plans/2026-05-28-cashu-cli.md) | NIP-60/61/87 Cashu wallet verbs in amy; full command surface ships, production-mint interop harness pending. |
| nestsClient | [t16-closure-roadmap](nestsClient/plans/2026-05-07-t16-closure-roadmap.md) | Priorities 1 & 2 closed and suite passes, but CI gating deferred and framesPerGroup rerun + two upstream items open. |
| docs | [viewport-aware-metadata-loading](docs/plans/2026-04-29-perf-viewport-aware-metadata-loading-plan.md) | Base preloader/rate-limiter infra exists but the LazyListState/snapshotFlow viewport selection isn't clearly wired. |
| docs | [macos-bunker-relogin](docs/plans/2026-06-18-fix-desktop-macos-bunker-relogin-plan.md) | PR 1 defense-in-depth shipped, but the cold-boot root cause is still open/unidentified. |
### Queued (8)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| amethyst | [napplet-inter-applet](amethyst/plans/2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not built. |
| quartz | [local-headers-explorer](quartz/plans/2026-05-08-local-headers-explorer.md) | Headers-only Bitcoin P2P client to verify NIP-03 OTS attestations without a trusted block explorer. |
| quartz | [giftwrap-deletion-requests](quartz/plans/2026-06-12-giftwrap-deletion-requests.md) | Let a recipient-authored kind-5 delete/block a gift wrap (kind 1059) addressed to them. |
| quartz | [incremental-negentropy-storage](quartz/plans/2026-07-03-incremental-negentropy-storage.md) | Always-current (created_at, id) index so cold NEG-OPENs stop paying a full scan + seal. |
| commons | [event-renderer](commons/plans/2026-04-21-event-renderer.md) | Cross-platform UI-agnostic `RenderedEvent` subsystem shared by Amy, Desktop, Android; not started. |
| commons | [amethyst-to-commons-migration](commons/plans/2026-05-30-amethyst-to-commons-migration.md) | Roadmap to move shared `amethyst` Android code into `commons`; keystone `Account`/`LocalCache` extraction not begun. |
| desktopApp | [embedded-wallet-phase2-research](desktopApp/plans/2026-05-21-embedded-wallet-phase2-research.md) | Research for an embedded self-custodial Lightning wallet (Breez/ldk-node/lightning-kmp); parked, no code. |
| nestsClient | [cross-stack-interop-ci-gating](nestsClient/plans/2026-05-07-cross-stack-interop-ci-gating.md) | CI gating for the cross-stack interop suite; infra built then removed over wallclock cost, kept as a ready revisit target. |
| nestsClient | [framespergroup-production-rerun](nestsClient/plans/2026-05-07-framespergroup-production-rerun.md) | Re-run the two-phone field tests to settle the framesPerGroup test-pin (5) vs default (50); needs prod-rig access. |
### Abandoned (3)
| Module | Plan | Summary |
| ------ | ---- | ------- |
| quic | [congestion-control](quic/plans/2026-05-05-congestion-control.md) | NewReno congestion control parked indefinitely; the real concern was solved by the smaller `SendBuffer.bestEffort` fix instead. |
| docs | [desktop-relay-config-single-source](docs/plans/2026-04-23-feat-desktop-relay-config-single-source-plan.md) | Single `DesktopRelayConfig` class never built; relay state landed as `DesktopRelayCategories`/`LocalRelayCategories` instead. |
| docs | [macos-vlc-bundled-discovery](docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md) | macOS bundled-VLC `setenv` discovery fix; moot after VLC/VLCJ was removed entirely in the kdroidFilter migration. |
+5 -5
View File
@@ -297,16 +297,16 @@ repositories {
Add the following line to your `commonMain` dependencies:
```gradle
implementation('com.vitorpamplona.quartz:quartz:1.12.4')
implementation('com.vitorpamplona.quartz:quartz:1.12.6')
```
Variations to each platform are also available:
```gradle
implementation('com.vitorpamplona.quartz:quartz-android:1.12.4')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.4')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.4')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.4')
implementation('com.vitorpamplona.quartz:quartz-android:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-jvm:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iosarm64:1.12.6')
implementation('com.vitorpamplona.quartz:quartz-iossimulatorarm64:1.12.6')
```
Check versions on [MavenCentral](https://central.sonatype.com/search?q=com.vitorpamplona.quartz)
+17
View File
@@ -44,6 +44,23 @@ workflow.
e.g. `v1.12.01.md`) and add it to `docs/changelog/README.md`. Follow the
house style: plain text, short verb-first sentences.
For the `## Translations` section, generate the credits instead of writing
them by hand — no token needed:
```bash
scripts/translators.sh
```
This runs offline. It reads `docs/changelog/translators.json` (kept next to
the changelogs), which CI keeps fresh: the Crowdin sync workflow's
`seed-translators` job records everyone who has translated since the last `v*`
tag, with their languages, in the file's `sinceLastTag` list, and accumulates
their npubs in the forever-growing `mappings` registry. The script resolves
that list to npubs and prints the `## Translations` block grouped by language.
Contributors with no npub yet are listed under `UNMAPPED` — credit them by
hand, then add their npub under `mappings` so future releases pick them up
automatically. (To re-query Crowdin live as a sanity check, run
`scripts/translators.sh --seed` with `CROWDIN_PROJECT_ID` /
`CROWDIN_PERSONAL_TOKEN` set, which refreshes the file.)
3. **Publish the release-notes note on Nostr** with Amethyst's account and paste
its event id into `amethyst/build.gradle.kts`:
```kotlin
+39
View File
@@ -264,6 +264,23 @@ android {
resValues = true
}
// Reproducible builds: keep AGP from embedding the dependency-metadata blob
// in the APK/AAB. That blob is a protobuf of the resolved dependency tree
// encrypted with a Google public key; the ciphertext is non-deterministic,
// so its presence makes every release artifact impossible to reproduce
// bit-for-bit. Dropping it (F-Droid's documented recommendation) lets
// F-Droid / Zapstore independently rebuild and verify our developer-signed
// APKs.
//
// Play-channel trade-off: with includeInBundle = false the uploaded .aab no
// longer carries this metadata, so Play Console's app-dependency insights /
// known-vulnerability SDK alerts go unpopulated. Uploads still succeed; only
// that advisory feature is lost.
dependenciesInfo {
includeInApk = false
includeInBundle = false
}
packaging {
resources {
excludes += listOf("/META-INF/{AL2.0,LGPL2.1}", "**/libscrypt.dylib")
@@ -331,12 +348,31 @@ composeCompiler {
dependencies {
implementation(platform(libs.androidx.compose.bom))
// Compose composition tracing — DEBUG ONLY, profiling aid (not shipped). Makes each
// recomposition show up as a NAMED slice in Perfetto system traces so we can see which
// composable recomposes (e.g. during the cold-start feed first-paint). All Apache-2.0.
// Usage: runtime-enable, then capture a Perfetto trace with the `track_event` data source:
// adb shell am broadcast -a androidx.tracing.perfetto.action.ENABLE_TRACING \
// -n com.vitorpamplona.amethyst.debug/androidx.tracing.perfetto.TracingReceiver
debugImplementation("androidx.compose.runtime:runtime-tracing")
debugImplementation("androidx.tracing:tracing-perfetto:1.0.0")
debugImplementation("androidx.tracing:tracing-perfetto-binary:1.0.0")
implementation(project(":quartz"))
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(project(":nappletHost"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
// Hardened WebView host for sandboxed napplet/nsite rendering (origin-restricted message bridge).
implementation(libs.androidx.webkit)
// Client side of the cross-process UI embedding: renders the sandboxed browser surface (hosted in
// the keyless `:napplet` process) inside a Compose component in the main app.
implementation(libs.androidx.privacysandbox.ui.core)
implementation(libs.androidx.privacysandbox.ui.client)
implementation(libs.androidx.ui)
implementation(libs.androidx.ui.graphics)
implementation(libs.androidx.ui.tooling.preview)
@@ -421,6 +457,9 @@ dependencies {
implementation(libs.markdown.ui.material3)
implementation(libs.markdown.commonmark)
// Syntax highlighting for the git repository code browser (Apache-2.0)
implementation(libs.highlights)
// LaTeX math rendering ($...$ and $$...$$ inline equations)
implementation(libs.jlatexmath.android)
implementation(libs.jlatexmath.font.greek)
+3
View File
@@ -1,5 +1,8 @@
# iOS Support for Amethyst
> **Status:** in-progress — `iosArm64`/`iosSimulatorArm64` targets are configured in `quartz` and `commons` (Phase 1), but no `iosApp` module exists yet — later phases not built.
> _Audited 2026-06-30._
**Date:** 2026-05-24
**Status:** Phase 1 complete; Phase 2 in flight
**Owner:** TBD
@@ -0,0 +1,97 @@
# Napplet inter-applet communication (NAP-INC / NAP-INTENT) — design notes
> **Status:** queued — Explicitly deferred; no `MESSAGING` capability exists in `NappletCapability` and the prerequisites (multi-applet hosting, archetype registry) are unbuilt.
> _Audited 2026-06-30._
**Date:** 2026-06-20
**Status:** Deferred — design only. Prereqs not yet built (see below).
**Parent:** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
## Why this is deferred (not just "next")
Inter-applet messaging is the one napplet capability that needs **new
architecture**, not just a new broker op + gateway. Two hard prerequisites are
missing today:
1. **Multiple applets running at once.** `NappletHostActivity` is declared
`launchMode="singleTask"` and hosts exactly one applet. True live A↔B
messaging (the full NAP-INC request/result transport) requires either
multi-applet hosting (several iframes in one host, or several host processes)
plus a routing layer — none of which exists.
2. **An archetype / handler registry.** NAP-INTENT dispatches by *archetype*
(`note`, `feed`, `profile`, …) to a default-handler napplet. Our
`NappletManifest` has no `handles`/archetype declaration and there is no
"which napplet is the default handler for X" registry.
Both are sizeable subsystems. Shipping a half-version would also risk **forking
the wire format** from upstream while it is still being defined.
## Upstream model (napplet/naps survey, 2026-06-20)
Inter-applet is split into two shell-mediated specs (applets never reach each
other directly — every message crosses the shell/broker):
- **NAP-INC** (`inc`) — the transport. Messages are `{ type: "domain.action",
id, … }`, request/result correlated by `id`. Addressing is direct
(napplet→napplet) *or* archetype-mediated by the runtime.
- **NAP-INTENT** (`intent`) — invoke a napplet by **archetype** via
default-handler dispatch (`shell.supports("intent")`). The shell launches the
handler; napplets cannot invoke directly.
- **NAP-1…5** — concrete protocols on top of NAP-INC: `profile:*` (NAP-1),
`stream:*` (NAP-2), `chat:*` (NAP-3), `note:open` (NAP-4), `feed:*` (NAP-5).
Producer/consumer model.
Discovery is capability-probe based: `shell.supports("inc")`,
`shell.supports("inc", "NAP-N")`.
## How it would map onto our boundary
The broker model fits "shell-mediated" naturally — every message would cross
`NappletBrokerService` exactly like every other capability, gated by the ledger.
The pieces:
1. **Capability.** Add `NappletCapability.MESSAGING` mapped from NAP domains
`inc` / `intent` (default-deny like every other domain). Consent is a *link*
grant ("Applet A may message / open Applet B"), distinct from per-op consent.
2. **Protocol.** New `NappletRequest`/`NappletResponse` variants under
`MESSAGING`, shaped to mirror NAP-INC (`type = "domain.action"`, `id`
correlation) so we don't fork the wire format.
3. **Addressing.** Direct by napplet coordinate first; archetype dispatch only
after the registry (below) exists.
### Two viable implementation shapes (pick at build time)
- **NAP-INTENT, direct coordinate** — `napplet.intent({ target, payload })` →
consent → broker resolves `target` to a manifest in `LocalCache` → launches it
via a `NappletIntentLauncher` gateway, passing an initial payload the target
reads on startup (`napplet.intent` / `onIntent`). Fits the single-applet model
(you switch to the target). No simultaneous hosting needed. **Lowest lift; most
aligned with NAP-INTENT.** Result-return across the switch is awkward (fire-and-
forget, or a callback event).
- **NAP-INC brokered mailbox** — `napplet.sendTo(coordinate, msg)` /
`pollMessages()` with broker-persisted per-napplet inboxes, consent per link.
Works with no simultaneous hosting and is fully unit-testable, but it is async
fire-and-collect, not the request/result transport upstream describes.
Full live NAP-INC (simultaneous A↔B, request/result) needs the multi-applet
hosting prereq regardless.
## Prerequisites to build first
1. **Multi-applet hosting** — either N iframes in one `NappletHostActivity` with
per-iframe origin isolation + routing, or a host-per-applet process model and
a cross-process router in the broker. Decide the model before coding NAP-INC.
2. **Archetype registry** — a manifest `handles`/archetype tag (align with
upstream naps), an index over installed napplets, and a user-set default
handler per archetype (mirror NIP-89 handler selection, which Amethyst already
models for app recommendations).
3. **Link-consent UX** — distinct from capability consent: "Allow *Chess* to open
*Wallet*?", revocable per pair in a permissions screen.
## Recommendation
When picked up: start with **NAP-INTENT direct-coordinate** (smallest, aligned,
no new hosting), build the archetype registry next (unlocks default-handler
dispatch + reuses NIP-89 patterns), and only then tackle live NAP-INC once
multi-applet hosting lands. Keep the wire `type`/`id` shape identical to upstream
NAP-INC throughout to avoid a fork.
+36
View File
@@ -0,0 +1,36 @@
# amethyst plans
_Audited 2026-06-30. 21 plans: 19 shipped (archived), 1 in-progress, 1 queued, 0 abandoned._
## In progress
| Plan | Summary |
| ---- | ------- |
| [2026-05-24-ios-support.md](2026-05-24-ios-support.md) | Incremental KMP-to-iOS port; quartz/commons iOS targets are configured (Phase 1) but no `iosApp` module exists yet. |
## Queued
| Plan | Summary |
| ---- | ------- |
| [2026-06-20-napplet-inter-applet.md](2026-06-20-napplet-inter-applet.md) | NAP-INC / NAP-INTENT inter-applet messaging — deferred; prerequisites (multi-applet hosting, archetype registry, `MESSAGING` capability) not yet built. |
## Archived (shipped)
| Plan | Summary |
| ---- | ------- |
| [archive/2026-05-14-onchain-zaps.md](archive/2026-05-14-onchain-zaps.md) | NIP-BC (kind 8333) onchain Bitcoin zaps — hand-rolled `quartz/nipBCOnchainZaps/` consensus layer plus Android send/receive/display. |
| [archive/2026-05-25-appfunctions-signer-prompts.md](archive/2026-05-25-appfunctions-signer-prompts.md) | How AppFunctions write verbs acquire signatures across the three signer types; write verbs now ship. |
| [archive/2026-05-26-appfunctions-gemini-discovery.md](archive/2026-05-26-appfunctions-gemini-discovery.md) | Verifying Gemini-side discovery of Amethyst's AppFunctions; description-based (`isDescribedByKDoc`) discovery shipped. |
| [archive/2026-05-26-appfunctions-screens-as-verbs.md](archive/2026-05-26-appfunctions-screens-as-verbs.md) | Map every Amethyst screen's feed filter to an AppFunction/MCP verb; 46 verbs now ship. |
| [archive/2026-05-26-avif-implementation-plan.md](archive/2026-05-26-avif-implementation-plan.md) | Task-by-task plan for AVIF support via a `MediaMimeTypes` helper across the upload pipeline. |
| [archive/2026-05-26-avif-support.md](archive/2026-05-26-avif-support.md) | Comprehensive design making AVIF a first-class image format on every Amethyst surface. |
| [archive/2026-05-27-avif-instrumented-tests-design.md](archive/2026-05-27-avif-instrumented-tests-design.md) | Design for on-device AVIF upload-pipeline regression tests with committed fixtures. |
| [archive/2026-05-27-avif-instrumented-tests-plan.md](archive/2026-05-27-avif-instrumented-tests-plan.md) | Implementation plan for the AVIF instrumented + JVM unit tests and their fixtures. |
| [archive/2026-06-01-dm-live-tail-and-history-slices.md](archive/2026-06-01-dm-live-tail-and-history-slices.md) | DM loading split into a fixed live tail plus per-relay backward history paging (`WindowLoadTracker` / `RelayLoadingCursors`). |
| [archive/2026-06-19-napplet-sandbox-host.md](archive/2026-06-19-napplet-sandbox-host.md) | Keyless `:napplet`-process WebView host with brokered, consent-gated capabilities for NIP-5A/5D content. |
| [archive/2026-06-20-napplet-ecosystem-audit.md](archive/2026-06-20-napplet-ecosystem-audit.md) | Audit of our napplet shell against the upstream `@napplet` SDK; wire-compat gaps subsequently closed. |
| [archive/2026-06-21-napplet-code-audit.md](archive/2026-06-21-napplet-code-audit.md) | Code audit of the napplet subsystem recording correctness/perf/refactor fixes and deferred items. |
| [archive/2026-06-21-napplet-sdk-conformance-audit.md](archive/2026-06-21-napplet-sdk-conformance-audit.md) | Feature-by-feature SDK conformance audit; the four conformance breakers were fixed and pinned by tests. |
| [archive/2026-06-22-napplet-nsite-security.md](archive/2026-06-22-napplet-nsite-security.md) | Security review of the nsite/napplet attack surface; launch-token identity and per-applet origins landed. |
| [archive/2026-06-23-napplet-nap-theme-notify-inc.md](archive/2026-06-23-napplet-nap-theme-notify-inc.md) | Add the `theme`, `notify`, and `inc` NAP domains so demo napplets boot; capabilities + `NappletIncBus` shipped. |
| [archive/2026-06-24-napplet-embedded-tabs.md](archive/2026-06-24-napplet-embedded-tabs.md) | Embedded warm bottom-bar napplet/nsite/browser tabs via `SurfaceControlViewHost`, in the new `:nappletHost` module. |
| [archive/2026-06-25-embed-text-selection-native-parity.md](archive/2026-06-25-embed-text-selection-native-parity.md) | Host-drawn text selection (handles, magnifier, toolbar, IME proxy) for embedded sandboxed surfaces. |
| [archive/2026-06-25-web-app-naming.md](archive/2026-06-25-web-app-naming.md) | Naming overhaul for web-app / nApplet / nSite / favorite surfaces (route, screen, controller renames). |
| [archive/2026-06-26-nsite-napplet-favorite-icons.md](archive/2026-06-26-nsite-napplet-favorite-icons.md) | Derive favorited nSite/nApplet bottom-nav icons from verified manifest blobs (`NappletIconPath`). |
@@ -1,5 +1,8 @@
# Onchain Zaps in Amethyst
> **Status:** shipped — Full `quartz/nipBCOnchainZaps/` consensus + builder + verify layer ships with Android model/UI wiring (`OnchainZapResolver`, `OnchainZapEvent` view, `ReusableZapButton`).
> _Audited 2026-06-30._
**Date:** 2026-05-14
**Status:** Active
@@ -1,5 +1,8 @@
# AppFunctions signer prompts — design
> **Status:** shipped — `AmethystAppFunctions.kt` ships 46 `@AppFunction` verbs including write verbs that acquire signatures across signer types.
> _Audited 2026-06-30._
**Date:** 2026-05-25
**Status:** Draft — no code yet
@@ -1,5 +1,8 @@
# Verifying Gemini-side AppFunctions discovery
> **Status:** shipped — Verification doc; verbs ship with `@AppFunction(isDescribedByKDoc = true)` description-based discovery as the doc concludes.
> _Audited 2026-06-30._
**Date:** 2026-05-26
**Status:** Active — answers the open question from
`2026-05-25-appfunctions-signer-prompts.md`
@@ -1,5 +1,8 @@
# All Amethyst screens as AppFunctions / MCP endpoints
> **Status:** shipped — 46 `@AppFunction` verbs ship in `AmethystAppFunctions.kt`, exceeding the screen-to-verb surface this plan proposed.
> _Audited 2026-06-30._
**Date:** 2026-05-26
**Status:** Active — informs the v1 read-verb surface and guides
future MCP work
@@ -1,5 +1,8 @@
# AVIF Support Implementation Plan
> **Status:** shipped — `service/uploads/MediaMimeTypes.kt` and the rest of the AVIF upload pipeline are present in `amethyst/`.
> _Audited 2026-06-30._
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make AVIF (still and animated) work as a first-class image format in Amethyst across every user-visible surface — uploads (Blossom + NIP-96), feeds, profiles, DMs, emoji packs, reactions, gallery, and caching — with graceful no-crash fallback on Android API < 31.
@@ -1,5 +1,8 @@
# AVIF support — comprehensive design
> **Status:** shipped — AVIF upload/display support landed — `MediaMimeTypes.kt` plus the compressor/stripper/preview changes are in tree.
> _Audited 2026-06-30._
**Issue:** [vitorpamplona/amethyst#837](https://github.com/vitorpamplona/amethyst/issues/837)
**Date:** 2026-05-26
**Branch:** `feat/avif-support` (based on `d1610bf97`, origin/main = upstream/main)
@@ -1,5 +1,8 @@
# Design — AVIF instrumented tests
> **Status:** shipped — The designed test files and AVIF fixtures exist under `amethyst/src/androidTest/` and `src/test/`.
> _Audited 2026-06-30._
**Date:** 2026-05-27
**Status:** Design (pre-implementation)
**Owning module:** `amethyst/`
@@ -1,5 +1,8 @@
# AVIF Instrumented Tests — Implementation Plan
> **Status:** shipped — `AvifUploadPipelineInstrumentedTest.kt`, `MediaMimeTypesTest.kt`, and the three `.avif` fixtures are committed.
> _Audited 2026-06-30._
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an Android on-device test layer that catches regressions in the AVIF upload pipeline and the Phase E bugs fixed manually (`C2``C8` plus follow-ups in commits `df84475d4`, `5a760c046`, `b9112550b`, `1db110cdf`).
@@ -1,5 +1,8 @@
# DM loading: live tail + per-relay history paging
> **Status:** shipped — Live-tail + per-relay history layers exist (`WindowLoadTracker`, `RelayLoadingCursors`, `AccountGiftWrapsHistoryEoseManager`); doc marks itself authoritative-as-of-code.
> _Audited 2026-06-30._
> **Status:** authoritative as of 2026-06-05. The "Current architecture"
> section below describes the code as it actually stands. The original
> time-slice design and the round-model history are kept at the bottom under
@@ -0,0 +1,274 @@
# Napplet / nsite sandbox host — design
> **Status:** shipped — Keyless sandbox host shipped: `:nappletHost` module, `NappletBrokerService`, `NappletLaunchRegistry` all present (rendering half superseded by embedded-tabs).
> _Audited 2026-06-30._
**Date:** 2026-06-19
**Status:** Core (commons) implemented + tested; Android host (`:napplet` process, WebView, broker IPC, consent, DataStore) implemented and compiling — needs on-device verification
**Companion:** `quartz/plans/2026-06-19-napplet-nip5a-resolver.md` (the bottom half — manifest parsing + verified Blossom resolution — already landed in `quartz`).
> **Update (2026-06-24):** the *rendering* model below (full-screen-only
> `NappletHostActivity`, host living in `amethyst/androidMain/.../napplet/`) has
> moved on. The sandbox runtime now lives in its own **`:nappletHost`** module and
> renders three ways — full-screen, embedded warm bottom-bar tabs, and an arbitrary-URL
> browser — with a cross-process `SurfaceControlViewHost`, a soft-keyboard IME proxy,
> and per-site Tor routing. See **`amethyst/plans/2026-06-24-napplet-embedded-tabs.md`**
> for the current architecture. The **trust model** in this doc (keyless `:napplet`
> process, brokered consent-gated capabilities, verified-blob serving) is unchanged.
## Goal
Render NIP-5A static sites (nsites) and NIP-5D napplets *inside* Amethyst, with
the applet's HTML/CSS/JS running behind a **hard trust boundary**: it must never
be able to read the user's `nsec` or any other secret, read app storage,
`LocalCache`, or other accounts' data, or sign / encrypt / publish / zap without
explicit per-applet user consent. A napplet is untrusted third-party code served
from an untrusted Blossom CDN; we treat it accordingly.
## Threat model
**Adversary:** the applet bundle (HTML/CSS/JS), authored by an untrusted party,
delivered from an untrusted CDN.
It must NOT be able to:
1. Read the `nsec` / any `NostrSigner`-held secret, or decrypted key material.
2. Read app private storage, `LocalCache`, DataStore, other accounts, or another
applet's sandboxed storage.
3. Sign, encrypt/decrypt, publish, subscribe, or zap without explicit consent.
4. Escalate to native code, other installed apps, or the file system.
5. Reach the network directly to exfiltrate or fingerprint (network is a *brokered
capability*, default-deny).
6. Forge content past the signed manifest (already handled by `StaticSiteResolver`:
manifest is authority, CDN is untrusted, every blob is sha256-verified).
**Trusted:** the main Amethyst process, the broker, the consent UI, quartz
verification. **Assumption:** the Android System WebView (Chromium) renderer
sandbox is sound — and we add an OS-process boundary on top so that even a full
WebView/renderer escape lands in a process that holds no secrets.
## Why a separate OS process is the load-bearing decision
Android processes have isolated address spaces. The decrypted `privKey`, the
`KeyPair`, and the `NostrSigner` instance live **only in the main process heap**.
The applet host runs in a separate process (`android:process=":napplet"`) that:
- never constructs a `NostrSigner`, never touches `SecureKeyStorage`/Keystore,
never holds an `Account` or `LocalCache`;
- only holds an IPC handle to *request operations*, whose **results carry no key
material** (a signed event, a ciphertext, a pubkey — never the private key).
So even arbitrary code execution inside the WebView renderer (already its own
sandboxed process) or inside the `:napplet` app process cannot read the main
process's memory where the secret lives. This is the guarantee the same-process
approach cannot make.
## Process & component model
```
┌─────────────────────────────────────────┐ ┌───────────────────────────────────┐
│ Main process (com.vitorpamplona.amethyst)│ │ Applet process (…:napplet) │
│ │ │ │
│ Account · NostrSigner · SecureKeyStorage │ │ NappletHostActivity │
│ LocalCache · NostrClient · Blossom · NWC │ │ └─ WebView │
│ │ │ ├─ shell page (trusted, │
│ NappletBrokerService (bound, not exported)│◀──AIDL─▶│ │ app-asset origin) │
│ └─ commons NappletBroker │ Binder │ │ exposes bridge → broker │
│ └─ NappletPermissionLedger │ (UID │ └─ <iframe sandbox= │
│ │ checked)│ "allow-scripts"> │
│ NappletConsentActivity (consent UI) │ │ = applet, opaque origin│
└─────────────────────────────────────────┘ └───────────────────────────────────┘
holds secrets holds NO secrets
```
### Three transport hops (each a trust step-down)
1. **applet iframe ↔ shell page**`postMessage` with strict origin checks. The
shell injects a tiny `window.napplet.*` client shim into the applet that wraps
postMessage calls into promises (request-id correlation). This is the NIP-5D
capability surface the applet codes against.
2. **shell page ↔ `:napplet` native** — exactly one audited bridge,
`WebView.addWebMessageListener` restricted to the **shell origin only** (the
applet's opaque-origin iframe cannot reach it). The shell forwards validated
requests.
3. **`:napplet` process ↔ main-process broker** — AIDL/`Messenger`. The broker
checks `Binder.getCallingUid() == Process.myUid()` (reject anything not from
our own app), consults the permission ledger, runs the operation with the real
signer/client, returns only the result.
### WebView hardening (`:napplet`)
- Shell document served from app assets via `WebViewAssetLoader` at a fixed
internal origin (`https://napplet.localhost/`). The applet lives in a child
`<iframe sandbox="allow-scripts">` **without `allow-same-origin`** → unique
opaque origin: no access to the shell DOM, cookies, `localStorage`,
`IndexedDB`, or the bridge.
- Applet resources are served by `shouldInterceptRequest` from an **in-memory map
of already-verified blob bytes** (resolved + sha256-checked by
`StaticSiteResolver` *before* the WebView loads). Only manifest-declared paths
resolve; everything else → 404. No `file://`, no `content://`.
- `WebSettings`: `allowFileAccess=false`, `allowContentAccess=false`,
`allowFileAccessFromFileURLs=false`, `allowUniversalAccessFromFileURLs=false`,
`setGeolocationEnabled(false)`, `mediaPlaybackRequiresUserGesture=true`,
`safeBrowsingEnabled=true`, no insecure mixed content. `domStorageEnabled`
only for a partitioned per-applet store (or off in v1).
- **CSP**: the shell injects `connect-src 'none'` for the applet by default — the
applet has *no direct network*. Relay/Blossom/identity all go through the
broker. Direct network is itself a capability (`net`) that widens `connect-src`
to user-approved origins only.
- External navigation blocked in `shouldOverrideUrlLoading`; links open in the
system browser only after consent.
## Capability / NAP-domain model
`NappletManifest.requires()` already yields the bare NAP domains
(`identity`, `relay`, `storage`, …; see `quartz/.../tags/RequiresTag.kt`). Map
each to a `NappletCapability` with the concrete broker operations it unlocks:
| NAP domain | Capability | Broker operations |
|---|---|---|
| `identity` | `IDENTITY` | `getPublicKey`, `signEvent`, `nip04/44 encrypt/decrypt` |
| `relay` | `RELAY` | `publish`, scoped `subscribe` (read) |
| `value` / `wallet` | `WALLET` | NIP-57 zap request / invoice; NWC pay (stricter, separate grant) |
| `storage` | `STORAGE` | per-applet sandboxed KV store, namespaced by applet identity — **never** app storage |
| `net` | `NET` | widen CSP `connect-src` to approved origins |
| *(unknown)* | — | **denied by default**, surfaced to the user |
**Applet identity for the ledger** = author pubkey + `d` identifier (the
addressable coordinate), *not* the blob hash, so grants survive updates. We still
record the manifest aggregate hash (`computeAggregateHash()`) so a user who picks
"ask again on code change" is re-prompted when the bundle changes.
## Permission ledger
`NappletPermissionLedger` — per-applet-identity grants, each
`NappletCapability → GrantState` (`ASK` / `ALLOW_ONCE` / `ALLOW_SESSION` /
`ALLOW_ALWAYS` / `DENY`), persisted via a `NappletPermissionStore` interface
(DataStore actual on Android). The broker consults it on every request:
- `DENY` → immediate `Denied` response.
- `ALLOW_*` → execute.
- `ASK` → suspend, launch `NappletConsentActivity` (main process), await the
user's decision, optionally persist, then execute or deny.
Consent UI reuses the signer-prompt design
(`amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`) and
`commons/.../ui/signing`.
### Capability-aware consent policy
The uniform "once / session / always / deny" is refined per capability:
- **Payments (`WALLET`)** — `requiresPerUseConsent = true`: every `payInvoice`
re-prompts with the decoded sats amount; the dialog never offers "Always allow"
and a stray always/session grant is downgraded to one-shot so nothing persists.
- **Identity (`IDENTITY`)** — gated by us **only when Amethyst holds the key**
(`NostrSignerInternal`). For remote (NIP-46) / external (NIP-55) signers the
broker defers to the signer's own per-request consent (no double-prompt), while
still honoring a standing per-napplet `DENY` and the `requires` declaration. The
sign prompt shows a kind + content preview.
- **Foreground-only execution** — the napplet WebView's JS/timers are paused when
the host isn't resumed (`onPause`/`onResume`), so a backgrounded applet cannot
fire a sign/decrypt/pay request whose prompt would surface over — and be
confused with — Amethyst's own UI. This is the precondition that makes deferring
identity to an external signer safe.
## Module placement
- **`quartz`** — protocol done. (Optional later: a canonical `NapDomain` constant
set once the upstream NAP list stabilizes — an open question in the resolver
plan.)
- **`commons/commonMain`** — new CLI-safe `napplet/` feature package:
- `napplet/NappletCapability.kt` — NAP-domain ↔ capability mapping.
- `napplet/protocol/``NappletRequest` / `NappletResponse` sealed families + JSON codec.
- `napplet/permissions/``NappletPermissionLedger`, `GrantState`, `NappletPermissionStore`.
- `napplet/NappletBroker.kt` — platform-agnostic broker: `(NostrSigner +
relay/blossom/zap handles + ledger) → (NappletRequest → NappletResponse)`.
The heart of the boundary; **fully unit-testable on the JVM**.
- `napplet/ui/` — shared consent composables.
- **`amethyst/androidMain`** —
- `NappletHostActivity` (`:napplet`) + WebView + `WebViewAssetLoader` + the
shell HTML/JS shim asset.
- `NappletBrokerService` (main process, bound, `exported=false`) wrapping the
commons broker; `Binder` UID check.
- `NappletConsentActivity` (main process) using the commons consent UI.
- AIDL/`Messenger` plumbing; OkHttp `BlobFetcher` wiring (the resolver plan's
named follow-up).
- AndroidManifest: `:napplet` process declaration, the bound service, the
consent activity.
- **`desktopApp`** — out of scope for v1 (the `:napplet` process model is
Android-specific; desktop needs a separate child-process/WebView strategy).
## Inter-applet communication (v2)
Napplets' differentiator is talking to each other. Model: applets address each
other by napplet coordinate; `napplet.send(target, msg)` is **routed through the
broker** (shell→broker→shell) so two opaque-origin iframes never share an origin
or memory, and the user (or a manifest-declared allowlist) consents to the link.
Deferred to v2; v1 nails the single-applet boundary first.
## Testing & verification
- **commons (now, JVM):** broker decision logic per capability
(granted/denied/ask), ledger state transitions + persistence semantics,
protocol JSON round-trips, and security cases — unknown NAP domain denied,
request for an undeclared path 404s without fetching, signer responses never
contain key bytes.
- **Android (needs emulator):** instrumented tests for the WebView host, the
opaque-origin iframe isolation, and the process boundary — flagged as on-device
verification.
## Phasing (within the "full napplet" milestone)
1. **Core (commons, tested)** — protocol + capability model + ledger + broker. ✅ done.
2. **Android host** — `:napplet` process + WebView + verified-blob serving via
`shouldInterceptRequest` + CSP. ✅ implemented (`NappletHostActivity`).
3. **Broker IPC + `identity` + `relay`** — Messenger broker
(`NappletBrokerService`), `window.napplet.*` shim, consent UI
(`NappletConsentActivity`), DataStore ledger. ✅ implemented.
4. **Capabilities beyond identity/publish:**
- **`relay` read** — `QueryEvents`: bounded live relay fetch (`fetchAll`,
EOSE/timeout) merged with `LocalCache`, newest-first. ✅
- **`storage`** — per-applet sandboxed KV store (`DataStoreNappletStorage`). ✅
- **`value`/`wallet`** — `PayInvoice` wired to the user's NWC wallet
(`sendZapPaymentRequestFor`); consent shows the decoded sats amount; throws
(→ `Failed`) on no-wallet/error/timeout. ✅ (needs on-device verification)
- **`net`** — CSP widening to approved origins. ⏳
5. **Capability enforcement** — the broker refuses any request whose capability is
not in the manifest's `requires` (passed host→broker as `declared`), before any
consent prompt. ✅
6. **Inter-applet (NAP-INC / NAP-INTENT)** — deferred; design + prerequisites in
`2026-06-20-napplet-inter-applet.md`. **`net`** capability and an install-style
up-front capability grant UI also remain. ⏳
### Implemented Android components (amethyst `…/napplet/`)
| File | Process | Role |
|---|---|---|
| `NappletHostActivity` | `:napplet` | WebView host: hardened settings, opaque-origin iframe, verified-blob `shouldInterceptRequest`, CSP, `window.napplet` shim, Messenger client |
| `NappletBrokerService` | main | Bound Messenger service; runs the commons `NappletBroker` against the live account; `exported=false` + UID check |
| `NappletConsentActivity` / `NappletConsentCoordinator` | main | Capability-consent dialog + suspend bridge to the broker |
| `DataStoreNappletPermissionStore` | main | Persistent grant store (`NappletPermissionStore` actual) |
| `NappletProtocolJson` / `NappletIpc` | both | JSON codec + Messenger wire contract |
| `NappletLauncher` | caller | Packs a verified manifest into the host Intent |
| `assets/napplet/shell.html` | `:napplet` | Trusted shell page that sandboxes the applet iframe and relays messages |
### Remaining before user-facing ship
- **On-device verification (needs emulator/device):** opaque-origin iframe really
excludes the bridge; CSP `connect-src 'none'` blocks fetch/XHR/WebSocket; a real
napplet renders and round-trips a `getPublicKey` / `signEvent` through consent.
- ✅ **UI entry point:** a "Napplets" drawer item → `NappletsScreen` that lists
cached napplet manifests (kinds 15129/35129) and launches the host.
- ✅ **Process isolation:** `Amethyst.onCreate` now skips `AppModules` entirely in
the `:napplet` process, so the account/signer are never loaded there. (Previously
`initiate()` loaded the account in every process — a real hole, now closed.)
- ✅ **Privacy:** the host routes blob fetches through the user's Tor SOCKS proxy
when active; the port is passed in by the launcher (main process) so the sandbox
process never touches the account-bound HTTP stack.
- ✅ **Relay discovery:** `NappletsFilterAssembler` (registered in
`RelaySubscriptionsCoordinator`, invoked by `NappletsScreen`) REQs kinds
15129/35129 from the user's read relays while the screen is open, so manifests
flow into `LocalCache` for the list to render.
- **Consent UX:** reuse `commons/.../ui/signing` styling; show the manifest title
and a per-capability rationale; batch-grant on first run.
@@ -0,0 +1,277 @@
# Napplet implementation audit vs the upstream SDK / demo runtimes
> **Status:** shipped — Audit doc; the wire-compatibility gaps it identified were subsequently closed (shell handshake, keys, upload now in the broker/codec).
> _Audited 2026-06-30._
**Date:** 2026-06-20
**Sources:** `github.com/napplet/naps` (NAP specs), `github.com/napplet/web`
(`@napplet/shim` SDK), `github.com/kehto/web` + `kehto.github.io/web/playground`
(reference runtime). Audited against our branch `claude/awesome-pasteur-xwiwad`.
## TL;DR
Our shell is a **correct and security-hardened NIP-5A/5D renderer + capability
broker** — process isolation, verified-blob serving, default-deny CSP,
capability-aware + signer-aware consent, foreground-only, permissions UI. Those
are *shell-quality* properties the demo runtimes don't even specify, and we're
ahead there.
**But it is not wire-compatible with the napplet ecosystem.** Real napplets are
built against `@napplet/shim`, which exposes a **namespaced** `window.napplet.*`
and a **`{type:"domain.action", id}`** postMessage envelope. We inject a **flat**
`window.napplet.*` and use a **`{id, payload:{op}}`** envelope. A napplet from the
kehto playground calling `window.napplet.relay.publish(...)` or
`window.napplet.shell.supports("relay")` hits `undefined` on our shell. So today:
**0 real ecosystem napplets run as-is.**
## Reference: the upstream surface
`window.napplet` namespaces (from `@napplet/web`):
| Namespace | Methods (upstream) |
|---|---|
| `shell` | `supports(domain)`**required of every runtime** |
| `identity` | `getPublicKey()`, `onChanged(handler)` |
| `keys` | signing / NIP-04 / NIP-44 (separate domain from `identity`) |
| `relay` | `subscribe(filters)` (live), `publish(event)`, `query(filters)`, `publishEncrypted` |
| `storage` | `get(key)`, `set(key, value)`, `remove(key)` |
| `inc` | `emit(type, payload)`, `on(type, handler)` (inter-napplet) |
| `resource` | `bytes(url)`, `bytesAsObjectURL(url)` — fetch https/blossom/nostr/data |
| `value` | shell-mediated value transfer + zaps (depends on `relay`) |
| `upload` | shell-mediated file/blob upload (Blossom; depends on `relay`) |
| `intent` | invoke a napplet by archetype |
| `theme`, `notify`, `media`, `config`, `outbox`, `ifc`, `cvm` | further domains |
**Envelope:** `{ type: "<domain>.<action>", id, ...payload }`
result `{ type: "<domain>.<action>.result", id, ok, ... }`.
**Sandbox:** `iframe sandbox="allow-scripts"` only (no `allow-same-origin`) — **we
match this exactly.**
## Coverage scorecard (our broker vs upstream domains)
| Domain | Status | Notes |
|---|---|---|
| `shell` | ✗ **missing** | `supports()` is foundational; without it well-behaved napplets bail early |
| `identity` | ◐ | `getPublicKey` ✓; `onChanged` ✗ (no push) |
| `keys` (sign/nip04/nip44) | ◐ | implemented, but lumped under our `IDENTITY` capability, not a `keys` domain |
| `relay` | ◐ | `publish` ✓, `query` ✓ (now live); `subscribe` (live) ✗, `publishEncrypted` ✗ |
| `storage` | ✓ | get/set/remove — shape matches (wire differs) |
| `value` | ◐ | we do `payInvoice` via NWC; upstream `value` is zaps/value-transfer (depends on relay) — different method shape |
| `resource` | ✗ | no `bytes(url)`; we only serve manifest subresources |
| `upload` | ✗ | no Blossom upload |
| `inc` | ✗ | deferred (see inter-applet plan) |
| `intent` | ✗ | deferred |
| `theme` `notify` `media` `config` `outbox` `ifc` `cvm` | ✗ | not modeled |
Our extra `NET` domain has **no upstream equivalent** — upstream fetching is
`resource`. Our `fromNapDomain` recognizes `identity/relay/value/storage/net`;
everything else (`shell`, `resource`, `upload`, `inc`, `intent`, `keys`, …) maps
to *unknown → denied*. So a manifest `requires: ["relay","shell","resource"]`
would have two of three flagged unknown — and `shell` is mandatory.
## The two interop blockers
1. **Envelope.** Real napplets send `{type:"relay.publish", id, event}` and await
`{type:"relay.publish.result", id, ok}`. We expect `{id, payload:{op:"publish"}}`
and reply `{id, response:{type:"published"}}`. Incompatible.
2. **API surface.** Upstream is namespaced (`relay.publish`, `identity.getPublicKey`,
`keys.signEvent`, `shell.supports`, `resource.bytes`). Ours is flat
(`getPublicKey`, `signEvent`, `publish`, `queryEvents`, `payInvoice`) + a
namespaced `storage`. Only `storage` lines up.
Both live in our **edge layer** (the injected JS shim + `NappletProtocolJson` +
`NappletIpc`) and the capability enum — *not* in the security core (broker,
ledger, process model), which is dialect-agnostic. So aligning is an edge rewrite,
not an architecture change.
## What we have that the demos don't
- Separate-OS-process sandbox (`:napplet`), not just an iframe.
- Verified-blob serving (manifest-authoritative, per-blob sha256) with default-deny
CSP (`connect-src 'none'`) and Tor-routed fetch.
- Capability **declaration enforcement** (manifest `requires` gate), **per-use**
payment consent, **signer-aware** identity deferral, **foreground-only** execution.
- A persisted permission ledger + a permissions-management UI.
These are real-shell concerns the reference runtimes leave to the implementer; we
should keep them.
## Recommended path to ecosystem compatibility (priority order)
1. **Adopt the upstream envelope** `{type:"domain.action", id}` / `…​.result` in the
shim + `NappletProtocolJson` + `NappletIpc`. (Unblocks everything.)
2. **Re-shape the injected shim** to the namespaced `window.napplet.*` and add
**`shell.supports(domain)`** (cheap; derive from the granted capability set).
3. **Split capabilities** to match domains: `keys` (sign/nip04/nip44) distinct from
`identity` (getPublicKey/onChanged); rename `NET``resource`; add `SHELL`,
`UPLOAD`, `VALUE` semantics (zap-by-target, not raw invoice).
4. **Add the push channel**: `relay.subscribe` (live events), `identity.onChanged`,
later `inc.on` — the host already holds a `JavaScriptReplyProxy` we can push
unsolicited `…event` messages through.
5. **`resource.bytes`** (consented fetch of https/blossom/nostr/data) and
**`upload`** (Blossom), both brokered + consent-gated.
6. Lower priority / app-specific: `theme`, `notify`, `media`, `config`, `outbox`,
`intent`, `inc`, `ifc`, `cvm`.
## Update (2026-06-20): ecosystem alignment landed
Acted on #1#5. The dialect mismatch is resolved:
- **Envelope** is now `{type:"<domain>.<action>", id}``{type:"…​.result", id, ok, …}`,
matching upstream (codec + host shuttle + shim rewritten; round-trip unit-tested).
- **Namespaced `window.napplet.*`** shim: `shell.supports`, `identity.getPublicKey`
(+`onChanged` stub), `keys.{signEvent,nip04*,nip44*}`, `relay.{publish,query,subscribe}`,
`storage.{get,set,remove}`, `value.payInvoice`, `resource.{bytes,bytesAsObjectURL}`,
`upload.blob`. The applet's own SDK-targeted code now runs unchanged.
- **`shell.supports(domain)`** implemented (no consent; reflects declared+brokered domains).
- **Capabilities split** to the domain model: `SHELL`, `IDENTITY`, `KEYS`, `RELAY`,
`STORAGE`, `VALUE`, `RESOURCE`, `UPLOAD` (was `IDENTITY/RELAY/WALLET/STORAGE/NET`).
- **`resource.bytes`** implemented for `https`/`data` (broker-fetched, Tor-routed,
consent-gated); `blossom:`/`nostr:` are a follow-up.
## Update (2026-06-21): return shapes verified against `@napplet/nap@0.15.0`
Pulled the canonical message types (`@napplet/nap` `*/types.d.ts` + `value-types`) and corrected
the result field names — several were wrong guesses. The authoritative wire:
| Method | Request `type` | Result field(s) |
|---|---|---|
| `identity.getPublicKey` | `identity.getPublicKey` | `pubkey: string` |
| `identity.getProfile` | `identity.getProfile` | `profile: ProfileData \| null` |
| `identity.getRelays` | `identity.getRelays` | `relays: Record<url, {read,write}>` |
| `identity.getFollows`/`getMutes`/`getBlocked` | same | `pubkeys: string[]` |
| `identity.getList` | `identity.getList` | `entries: string[]` |
| `identity.getZaps` / `getBadges` | same | `zaps[]` / `badges[]` |
| `storage.getItem/setItem/removeItem/keys` | **`storage.get`/`set`/`remove`/`keys`** | `value` / — / — / `keys: string[]` |
| `relay.publish` / `publishEncrypted` | same | template in the **`event`** field; result `{ok, event, eventId}` |
| `relay.query` | `relay.query` | `events: NostrEvent[]` |
| `resource.bytes` | `resource.bytes` | `blob: Blob`, `mime: string` |
`ProfileData` is `{ name?, displayName?, about?, picture?, banner?, nip05?, lud16?, website? }`
note **`displayName`** (camelCase), so the shell maps kind-0 `display_name``displayName` rather
than dumping raw content. Corrected in code: identity reads now emit method-specific fields;
`storage.*` wire types fixed (the *function* is `getItem`, the *envelope* is `storage.get`);
`storage.keys` returns `keys`; `relay.publish`/`publishEncrypted` read the template from `event`;
`getProfile` builds a `ProfileData` object. Locked by `NappletProtocolJsonTest`.
**Transport: structured-clone objects + subscription push — landed.** `@napplet/core` posts
**structured-clone objects** (not JSON strings) via `target.postMessage(obj)` and validates
cloneability — that is how `resource.bytes` returns a real `Blob`, and why a stock napplet's
object messages were dropped before (our shell only forwarded strings). Fixed:
- **`shell.html` bridges object↔string both ways.** applet→native serializes object envelopes to
the string the native bridge carries (requests carry no Blobs); native→applet parses the reply
to an object and posts a **structured-clone object** (what the SDK reads via `e.data.type`), not
a string. The injected shim accepts either form.
- **`resource.bytes` Blob.** The shell rebuilds a real `Blob` from the host's base64 `bytes`+`mime`
before delivering, so both the SDK and our shim resolve to a `Blob`.
- **`relay.subscribe` push channel.** Subscriptions are answered with `relay.event` (one per match)
then `relay.eose`, keyed by `subId` — no `.result`, matching the SDK. A new `MSG_PUSH` IPC frame
lets the broker push unsolicited envelopes the host forwards verbatim; `relay.close` is a
fire-and-forget no-op. Today this delivers the **initial snapshot then EOSE**.
Still open: a **live subscription tail** (push as events arrive, not just the snapshot) plus
`identity.onChanged`/`inc.on`; multi-`filters` queries (we use the first filter); the Blossom
`upload` gateway; and **on-device verification** — the shell/shim changes are JS and not exercised
by the JVM unit tests.
## Update (2026-06-21, even later): feed surfacing + nsite runtime hardening
- **Feed surfacing (sandbox-preserving).** Napplets gained an inline feed card (nsites already had
one), both render via `NoteCompose`, are indexed in `LocalCache`, and a profile **"Apps & Sites"**
tab lists a user's manifests. The cards are inert (`Text`+`Button`, no WebView); execution begins
only on explicit tap, in the `:napplet` process.
- **SPA route fallback** — a document navigation (Accept: text/html) to a route not in the manifest
serves the verified `index.html`; missing sub-resources still 404.
- **External-link handoff** — a user-tapped off-origin http(s) link opens in the system browser
(gesture-gated so a hostile site can't auto-redirect); the sandbox WebView never navigates away.
- **`resource.bytes` `blossom:` scheme** — `blossom:<sha256>` fetches from the user's kind:10063
Blossom servers and verifies the hash before returning. `nostr:` stays deferred (unspecified).
- **Content-type byte-sniffing** — when a manifest path has no/unknown extension, the resolver
sniffs magic bytes; text/markup is never sniffed, so HTML detection stays extension-driven.
Unit-tested in quartz.
- **kind:10063 fallback** — the launcher augments the manifest's `servers` with the author's
published Blossom list (best-effort); every blob is still sha256-verified.
- **Blob caching** — the host OkHttp client caches blobs on disk (forced-immutable, since they're
content-addressed); the resolver re-verifies every served blob, so a stale entry can't be served.
Remaining: live subscription tail + `identity.onChanged`/`inc.on`, the Blossom `upload` gateway,
`getList`/`getZaps`/`getBadges`, the `nostr:` resource scheme, multi-`filters` queries — and
**on-device verification** of all the WebView-host behavior.
## Update (2026-06-20, later): verified against `@napplet/shim@0.16.0` and corrected
Pulled the authoritative SDK (`@napplet/shim` v0.16.0, npm/unpkg) and corrected the
implementation to its real contract. Commit `5ca44e27` had carried several wrong guesses; the
verified surface is:
| Namespace | Verified methods (v0.16.0) |
|---|---|
| `shell` | `supports(domain, protocol?)` (sync), `ready()`, `onReady(cb)`, `services` |
| `identity` | `getPublicKey()`, `onChanged(h)`, + read API (`getRelays/getProfile/getFollows/getList/getZaps/getMutes/getBlocked/getBadges`) |
| `keys` | **keyboard/command actions**`registerAction/unregisterAction/onAction` (NOT signing) |
| `relay` | `publish(template, options?)` → signed `NostrEvent`, `publishEncrypted(template, recipient, encryption?)`, `query(filters)`, `subscribe(filters, onEvent, onEose, options?)` |
| `storage` | `getItem/setItem/removeItem/keys` (512 KB quota; `instance.*` variant) |
| `resource` | `bytes(url)``Blob`, `bytesAsObjectURL(url)` |
| `inc` | `emit(topic, extraTags?, content?)`, `on(topic, cb)` |
Crucial design fact, quoted: **"signing and encryption are mediated by the shell via
`relay.publish()` and `relay.publishEncrypted()`"** and *"no cryptographic dependencies — the
shim sends JSON envelope messages and the shell handles identity"*. **There is no `sign()` and no
raw nip04/44 in the napplet surface.** There is **no `value` or `upload` domain** in v0.16.0.
Corrections landed (this commit):
- **Signing model fixed (the big one).** Dropped the bogus `keys.signEvent` / `keys.nip04*` /
`keys.nip44*` napplet ops. `relay.publish` now takes an **unsigned template** (`kind/tags/content`)
and the broker signs it as the user and returns the signed event — exactly the upstream contract.
Added `relay.publishEncrypted` (broker encrypts to recipient with nip44/nip04, then signs +
publishes). The broker still defers the per-signature prompt to remote/external signers
(`signsAsUser` + non-internal signer) and honors standing DENY.
- **`keys` re-pointed to keyboard actions** (`registerAction/unregisterAction/onAction`),
implemented as client-side no-op stubs (not yet wired to the host keyboard) so action-using
napplets don't crash. They never cross the broker boundary.
- **`storage` renamed** to `getItem/setItem/removeItem` and **`storage.keys`** added end-to-end
(protocol + broker + DataStore + shim), matching upstream.
- **`resource.bytes` now returns a `Blob`** (shim builds it from `{bytes, mime}`); wire field
renamed `contentType``mime`.
- **`shell.supports(domain, protocol?)`** gained the optional protocol arg; added `shell.ready()`,
`onReady`, `services` stubs.
- **`relay.subscribe`** wired (initial matches; live tail still a follow-up).
- `value.payInvoice` and `upload.blob` are **kept as clearly-marked Amethyst-specific extensions**
(no upstream equivalent in v0.16.0) — a real `@napplet/shim` napplet never calls them, so they
can't conflict.
Verified off-device: `commons:jvmTest` (broker + capability + ledger) and the amethyst codec
round-trip test (`NappletProtocolJsonTest`) both green.
Still open (documented, not blocking basic napplets):
- **`upload`** — wired end-to-end (protocol/shim/capability) but the Android Blossom
gateway is unprovided (`Unsupported`): a correct upload needs a content Uri + signed
auth event + server selection, which needs on-device verification.
- **Live push** — `relay.subscribe` returns initial matches via `query`; a live tail and
`identity.onChanged`/`inc.on` need a push channel over the existing reply proxy.
- **Underspecified domains** — `inc`, `intent`, `theme`, `notify`, `media`, `config`,
`outbox`, `ifc`, `cvm` remain unknown→denied (no method spec available to build to).
- **Method-name fidelity** — ✅ resolved. All standard method names/shapes are now confirmed
against `@napplet/shim@0.16.0` (see the later update above), not guessed.
- **Identity read API** — ✅ partly landed. `identity.getProfile` (kind-0 content), `getRelays`
(NIP-65 read/write map), `getFollows` (kind-3 authors), `getMutes` and `getBlocked` (NIP-51
decrypted user tags) now read from the active `Account` and return JSON, gated by the IDENTITY
consent (and deferred to remote/external signers). `getList`/`getZaps`/`getBadges` route through
but degrade to `Unsupported` for now; `identity.onChanged` is still a client-side no-op (needs
the live push channel). Return shapes still want on-device verification against a real napplet.
- **On-device verification** of the whole round-trip with a real playground napplet.
Revised ecosystem-compatibility estimate: **~80%** — real request/response napplets using
identity(getPublicKey)/relay(publish/publishEncrypted/query/subscribe)/storage/resource +
`shell.supports` now run against the *verified* contract; remaining gaps are the identity read
API, keyboard-action wiring, live subscription tails, the niche domains, and device verification.
## Verdict (original assessment, pre-update)
- **As a secure NIP-5A/5D renderer + broker:** ~85% — the hard, security-critical
parts are done and tested; gaps are on-device verification and breadth of ops.
- **As an ecosystem-compatible napplet host (runs real napplets):** ~25% — blocked
by the envelope + namespaced-API mismatch and missing `shell`/`resource`. Until
#1#2 land, existing napplets won't run regardless of how solid the core is.
@@ -0,0 +1,65 @@
# Napplet subsystem code audit — bugs, performance, refactor, placement
> **Status:** shipped — Audit recording completed fixes (LiveSub teardown, broker caching, shim extraction); referenced files exist.
> _Audited 2026-06-30._
**Date:** 2026-06-21. Scope: the napplet/nsite subsystem (`amethyst/.../napplet/`,
`commons/.../napplet/`, `quartz/.../nip5aStaticWebsites` + `nip5dNapplets`).
## Fixed in this pass
| # | Category | Issue | Fix |
|---|---|---|---|
| 1 | 🐛 correctness | **Live subscription unsubscribed from the wrong client after an account switch**`closeLiveSubscription`/`onDestroy` used the *current* account's client, leaking the sub on the original. | `LiveSub` holder stores the exact `INostrClient` that opened the sub; teardown uses it. |
| 2 | 🐛 correctness | **Multi-relay subscriptions emitted N `relay.eose`** (one per relay) — the SDK expects one. | An `eoseSent` latch (`compareAndSet`) emits a single `relay.eose`. |
| 3 | ⚡ perf | **Broker rebuilt on every request** (new gateways/prompt each call). | `broker()` caches per account (reference identity), rebuilt only on switch. |
| 4 | ⚡ perf | **A fresh `OkHttpClient` per blob fetch** (no connection pooling). | `blobHttpClient()` caches the client keyed by Tor port (`@Synchronized`). |
| 5 | ♻️ refactor | **105-line `SHIM_JS` string constant** in `NappletHostActivity.kt` (no highlighting, hard to edit). | Moved to `assets/napplet/shim.js`, loaded once like `shell.html`. |
| 6 | 📝 docs | Stale shim comments (`onChanged` "follow-up", `subscribe` "snapshot…follow-up"). | Rewritten to match reality (no-op onChanged; live tail). |
All compile; `commons:jvmTest` + the amethyst napplet suite stay green.
## Deferred — with rationale (not silently dropped)
- **Duplicate events across relays** — the same event id can arrive from multiple relays, so
`relay.event` is pushed more than once. Deduping needs a per-subscription seen-id set (unbounded
memory for long subs); napplets already dedupe by id. Left as-is; documented.
- **Background teardown of live subscriptions** — a backgrounded-but-alive napplet keeps its relay
subscription open (the WebView is paused, but the service keeps streaming). It is *not* a
permanent leak: the service is bind-only, so closing the napplet → `unbindService` → service
`onDestroy` → all subs torn down. A proper pause/resume (unsubscribe on background, re-`REQ` on
foreground) is a real optimization but needs an IPC pause/resume signal + device verification.
- **Request ordering** — requests are handled concurrently (`scope.launch` per message), so
`storage.set` then `storage.get` aren't guaranteed in-order. Matches the SDK's async model;
serializing would hurt throughput. Documented, not changed.
- **`runBlocking` in `shouldInterceptRequest`** — this runs on a WebView *background* worker thread
(not the UI thread), so blocking there during a blob fetch is acceptable; WebView fans out
resource loads across workers. Left as-is.
- **Over-flags from the sweep that aren't real:** `pendingRequests` / `bridgeReplyProxy` "races" —
both the `WebMessageListener` callback and the reply `Handler` run on the **main looper**, so
there is no cross-thread access. A null `bridgeReplyProxy` only drops a reply to an
already-gone WebView (the applet is gone too) — harmless.
## Recommended moves to commons / quartz
- **quartz (protocol-only): correct as-is.** NIP-5A/5D events, `NappletManifest`,
`StaticSiteResolver` + `StaticSitePathLookup` (`sniffContentType`), `SiteAggregateHash` are all in
`commonMain`. No app policy leaked in.
- **commons (shared logic): mostly correct.** Broker, capability, identity, request/response,
permissions ledger/store, and gateway interfaces are in `commonMain` — right home.
- **DONE: `NappletProtocolJson``commons/jvmAndroid`.** Moved to
`commons/.../napplet/protocol/` (next to the types it marshals) so the future **desktop** host
reuses the exact wire codec. Its tests stay in `amethyst` (JUnit4) for now and still exercise it
via the commons dependency; converting them to `kotlin.test` and moving to commons `jvmTest` is a
small follow-up. See `desktopApp/plans/2026-06-21-napplet-desktop-host.md`.
- **amethyst (Android-only): correctly platform-bound.** `NappletHostActivity` (WebView/process),
`NappletBrokerService` (Service/Messenger/account), the gateway *implementations* (account,
`BlossomUploader`, DataStore, NWC), `NappletLauncher`, consent UI, `NappletIpc`, and the screens
all belong here.
## Lower-priority refactors (not done)
- Extract a shared Tor-aware OkHttp builder (host `buildHttpClient` vs service `blobHttpClient`
duplicate the proxy logic) into a small util.
- `summaryFor`'s long `when` could become a per-request-type method, and `encodeResponse`'s big
`when` is repetitive — both are readability, not correctness.
@@ -0,0 +1,131 @@
# Napplet SDK conformance audit — feature by feature
> **Status:** shipped — Conformance audit; the four breakers are marked fixed and pinned by `NappletSdkConformanceTest`.
> _Audited 2026-06-30._
**Date:** 2026-06-21
**Authoritative sources (verified, not from memory):**
`@napplet/nap@0.15.0` (`dist/<domain>/types.d.ts` — the canonical wire message types),
`@napplet/shim@0.16.0` (the SDK napplets bundle), `@napplet/core@0.15.0` (base envelope +
shell handshake). Audited against branch `claude/awesome-pasteur-xwiwad`.
Our edge layer: `NappletProtocolJson` (codec), `NappletRequest`/`NappletResponse` (commons),
`NappletHostActivity` (`SHIM_JS` + `shell.html` relay), `NappletBroker`, `NappletBrokerService`.
## Update — the four 🔴 breakers are now implemented
All four conformance breakers below are fixed (codec pinned by `NappletSdkConformanceTest`):
1. **Shell handshake** — the host answers `shell.ready` with `shell.init { capabilities:{domains,
protocols}, services }` built from the declared domains (`NappletProtocolJson.encodeShellInit`),
so a stock napplet's cached environment is populated and `supports()` works.
2. **Id-less messages** — `onShellMessage` no longer drops messages without an `id`: `shell.ready`
is answered locally, and other fire-and-forget messages get a synthetic id so they reach the broker.
3. **keys** — `keys.registerAction`/`keys.unregisterAction` decode and the broker acknowledges them
(declared-gated, no consent) so `registerAction()` resolves; the shim dispatches the `keys.action`
push. (The actual global-key binding is still a follow-up — `keys.action` isn't emitted yet.)
4. **upload** — realigned to `upload.upload { request:{ data, mimeType, filename } }` → rich
`UploadResult { ok, uploadId, status, url, sha256, size, mimeType }`; `shell.html` inlines the
request `Blob` as base64 so it survives the bridge; the gateway uploads via the app's
`BlossomUploader` to the user's kind:10063 server with a signed auth event.
Also now implemented (the ◐ follow-ups):
- **Live subscription tail** — `relay.subscribe` opens a real `client.subscribe` whose listener
streams `relay.event` (stored + live), `relay.eose`, and `relay.closed` pushes by `subId`;
`relay.close` unsubscribes (tracked in `liveSubs`, torn down in `onDestroy`).
- **Multi-`filters`** — `relay.query`/`subscribe` honor every filter in the `filters[]` array,
not just the first (`decodeFilterList`, gateway `query(List<Filter>)`).
- **`resource.cancel`** — accepted at the host edge as a no-op `Done`.
Still open: identity `getList`/`getZaps`/`getBadges` + `onChanged` (object shapes / list-type
semantics underspecified), the `keys.action` push (needs a host command-palette UI to *trigger*
actions — registration already conforms), the `resource` `nostr:` scheme (unspecified bytes),
`inc`/`intent`/the niche domains, and **on-device verification** of the host/shell behavior.
## Base envelope & error convention (verified)
- `NappletMessage` carries only **`type`** (`"domain.action"`). **There is no universal `id`** —
request/response pairs add `id`; fire-and-forget and handshake/push messages have **no `id`**.
- **No universal `ok`.** Each domain picks its own: `relay.publish`/`publishEncrypted`,
`outbox.publish`, `upload`, `intent` use `ok: boolean`; identity/storage/query results omit `ok`
and signal success by the data field's presence + an optional `error?: string`. The SDK shim
rejects when `error` is present and otherwise reads the domain's data field.
- **Ours:** we set `ok` on *every* result. Harmless (the SDK reads the data field and ignores the
extra `ok`), and our own injected shim relies on `ok`. ✅ compatible, ⚠️ non-canonical.
## Transport (verified) — two architectural gaps
1. **Structured-clone objects, not strings.** `@napplet/core` posts cloneable **objects**. Our
`shell.html` now bridges object↔string both ways (done earlier). ✅
2. **🔴 The host drops id-less messages.** `NappletHostActivity.onShellMessage` does
`id = optString("id").ifEmpty { return }` — so every message **without an `id`** is dropped:
`shell.ready`, `inc.emit`, `keys.unregisterAction`. This silently breaks the shell handshake and
all fire-and-forget messages. **Must fix** to forward/handle id-less messages.
3. **🔴 Blob-carrying requests can't cross.** `upload.upload`'s request payload contains a `Blob`
(`data: Blob | ArrayBuffer`). Our applet→native bridge does `JSON.stringify`, which turns a Blob
into `{}`. Real-napplet uploads lose their bytes. Needs a Blob-aware request path.
## Shell handshake (verified) — 🔴 not implemented
The SDK does **not** send a `shell.supports` message. Instead:
- napplet posts **`shell.ready`** (no payload), and
- the shell replies **once** with **`shell.init`** = `{ capabilities: { domains: string[],
protocols: Record<string,string[]> }, services: string[] }`.
- `shell.supports(capability, protocol?)` is then answered **synchronously and locally** from that
cached environment.
**Ours:** we implement a `shell.supports` *request* (`ShellSupports` → `Supported`) and our injected
shim calls it async. A real napplet never sends `shell.supports`; it sends `shell.ready` — which our
host **drops** (no id) — so its cached environment stays empty and `supports()` returns `false` for
everything, likely making well-behaved napplets bail early. **Highest-impact gap.**
Fix: host answers `shell.ready` with a `shell.init` carrying the declared domains.
## Per-domain conformance matrix
Legend: ✅ conformant · ◐ partial · 🔴 mismatch/missing · not modeled.
| Domain | SDK surface (wire) | Ours | Verdict |
|---|---|---|---|
| **shell** | `shell.ready`→`shell.init{capabilities,services}`; `supports()` local | `shell.supports` request→`{supported}` | 🔴 wrong model (no handshake) |
| **identity** | `getPublicKey`→`{pubkey}`; `getProfile`→`{profile}`; `getRelays`→`{relays}`; `getFollows`/`getMutes`/`getBlocked`→`{pubkeys}`; `getList`→`{entries}`; `getZaps`→`{zaps}`; `getBadges`→`{badges}`; `onChanged` push | getPublicKey ✅; profile/relays/follows/mutes/blocked ✅ (exact fields); getList/getZaps/getBadges → Unsupported; onChanged no-op | ◐ reads ✅, push/3 methods missing |
| **relay** | `publish{event}`→`{ok,event,eventId}`; `publishEncrypted{event,recipient,encryption}`; `query{filters[]}`→`{events}`; `subscribe{subId,filters,relay?}`; `close{subId}`; pushes `relay.event{subId,event,resources?}`, `relay.eose{subId}`, `relay.closed{subId,reason?}` | publish/publishEncrypted ✅ (read `event`); query ◐ (first filter only); subscribe ✅ + event/eose push ✅ (snapshot); close ✅ (no-op); `relay.closed` not emitted | ◐ strong; multi-filter + live tail + `relay.closed` open |
| **storage** | `get`→`{value}`; `set`; `remove`; `keys`→`{keys}` (512 KB quota) | ✅ exact (`get/set/remove/keys`, `value`/`keys` fields) | ✅ (quota not enforced) |
| **resource** | `bytes{url}`→`{blob,mime}`; `cancel`; https/blossom/nostr/data | bytes ✅ (shell builds Blob); https/data/blossom ✅; nostr ; cancel | ◐ nostr + cancel missing |
| **keys** | `registerAction{action}`→`{actionId,binding?}`; `unregisterAction{actionId}`; pushes `keys.action{actionId}`, `keys.bindings{bindings[]}` | client-side no-op stubs only; **host rejects** `keys.*` (decodes to null) | 🔴 real napplets' `registerAction` rejects |
| **upload** | `upload.upload{request:{data:Blob,mimeType?,...}}`→`{ok,uploadId,status,url?,sha256?,...}`; `upload.status` | type `upload` + `{bytes(base64),contentType}`→`{url}`; gateway null→Unsupported | 🔴 wrong type + shape + Blob transport |
| **inc** | `inc.emit`; `inc.subscribe`/`.result`; `inc.unsubscribe`; `inc.event` push; channel mode (`inc.channel.*`) | (deferred; maps to null→denied) | not modeled |
| **intent** | invoke a napplet by archetype | | |
| **theme/notify/media/config/outbox/ifc/cvm** | further domains | | |
## Inconsistencies, ranked
1. **🔴 Shell handshake missing** (`shell.ready`→`shell.init`). Real napplets get an empty
capability environment → `supports()` false → likely bail. *Fix: host emits `shell.init`.*
2. **🔴 Id-less messages dropped** by the host. Breaks the handshake + every fire-and-forget
(`inc.emit`, `keys.unregisterAction`). *Fix: forward/handle id-less messages.*
3. **🔴 `keys.*` rejects** for real napplets (we only stub client-side). *Fix: decode
`keys.registerAction`/`unregisterAction`, answer a stub `{actionId}`; later wire `keys.action`.*
4. **🔴 `upload` non-conformant** (`upload` vs `upload.upload`, flat base64 vs `request:{data:Blob}}`,
`{url}` vs rich `UploadResult`) AND the Blob can't cross our string bridge. *Fix: realign the
wire + a Blob-aware request path when the gateway lands.*
5. **◐ `relay.query`/`subscribe` use only the first of `filters[]`.** Multi-filter napplets get
partial results. *Fix: honor all filters.*
6. **◐ Identity `getList`/`getZaps`/`getBadges` + `onChanged`** unimplemented.
7. **◐ `resource` `nostr:` scheme + `resource.cancel`** unimplemented.
8. **◐ `relay.closed` push** not emitted; **live subscription tail** absent (snapshot only).
9. **⚠️ Non-canonical `ok` on every result** (harmless, but not how the SDK signals success for
identity/storage/query).
## What the conformance tests assert
`NappletSdkConformanceTest` (amethyst, JVM) pins the codec to the **exact SDK wire** for the
methods we support, so a regression that drifts from `@napplet/nap` fails CI:
- **Requests:** the SDK's exact envelope (`relay.publish{event}`, `storage.get`, `identity.*`,
`resource.bytes`, multi-`filters`) decodes to the right `NappletRequest`.
- **Results:** `encodeResponse` emits the SDK's exact field names (`pubkey`, `profile`, `relays`,
`pubkeys`, `entries`, `value`, `keys`, `events`, `event`/`eventId`, `bytes`/`mime`).
- **Pushes:** `relay.event{subId,event}` / `relay.eose{subId}` match the SDK push shapes.
- **Gap guards:** tests that *document current behavior* for the known gaps (`shell.ready`,
`keys.registerAction`, `upload.upload`, `inc.emit` currently decode to `null`), each annotated
with the audit item so the day we fix them the guard flips intentionally.
@@ -0,0 +1,114 @@
# Napplet / nsite security review (2026-06-22)
> **Status:** shipped — Security review recording completed hardening; `NappletLaunchRegistry` token model and per-applet origins are in tree.
> _Audited 2026-06-30._
A review of the attack surface for NIP-5A static sites (nsites) and NIP-5D napplets,
the protections in place, and the residual risks — with what was fixed in this pass
and what remains as future work.
## Trust model (what holds)
- **Keys never enter the sandbox.** The `:napplet` process holds no account/signer.
Signing happens only in the main process (`signer` fixes `pubkey`, host clock
prevents backdating). *"Even a full WebView/renderer escape into this process yields
no secret."*
- **Content integrity.** Every blob is sha256-verified against the **signed** manifest
before serving (`StaticSiteResolver`); cache is content-addressed + re-verified, so a
poisoned/stale cache can't be served. nsites launch with **zero** capabilities.
- **Network containment.** App CSP `connect-src 'none'`; egress only via the brokered,
consent-gated `resource.bytes`, Tor-routed. Bridge is origin-restricted + main-frame.
- **IPC binding** is `exported=false` + same-UID (`onBind` UID check). Payments always
prompt per-use with the amount.
## Fixed in this pass
1. **Cross-napplet identity/storage spoofing (was: per-message identity).** The broker
used to trust `author`/`identifier`/`declared` sent on **every** IPC message from the
`:napplet` process. A WebView→native escape could forge another napplet's coordinate
and read/act as it. Now the **main process** mints a random launch token
(`NappletLaunchRegistry`), hands only that token to the sandbox, and the broker
resolves it back to the trusted identity + declared set. A compromised sandbox can act
as nothing but the napplet it was launched as (it holds only its own token).
2. **Private mute/block leak via `identity.getMutes`/`getBlocked`.** These read
`muteList.flow` / `blockPeopleList.flow`, which contain **decrypted private** entries.
Now they read the events' **public** tags only (`MuteListEvent.publicMutes()`,
`PeopleListEvent.publicUsersIdSet()`).
3. **Silent "allow-always" actions + UI-redress.** Added persistent **trusted chrome**
(a sandbox bar the applet can't draw over: shield + name + tap-to-see "what it can
access") and a **live toast** when a granted RELAY/UPLOAD/VALUE op runs — so an
allow-always grant can't act completely silently. Also fixes the host drawing under
the status/navigation bars (edge-to-edge insets).
4. **Real per-applet storage origin (was: opaque sandbox → broken apps).** The applet
ran in an `allow-scripts`-only iframe served under `napplet.local/app/`, i.e. an
**opaque ("null") origin**. That has no `localStorage`/`IndexedDB`/service worker
(reads throw `SecurityError`), and module scripts / asset fetches are CORS-blocked, so
essentially every bundled SPA rendered **blank** or **crash-looped** ("cache version
0 → reset → reload" forever, because IndexedDB never persisted the version). Each
applet now loads on its **own** internal origin `https://<id>.napplet.local`
`id = sha256(author + ":" + identifier)`, truncated + letter-prefixed to a DNS label —
with `allow-scripts allow-same-origin`, served at the **origin root** (bundlers emit
absolute `/assets/...` URLs). A real origin restores DOM storage / IndexedDB / SW and
makes the applet's own assets same-origin (no CORS shim needed). **Isolation is
preserved precisely because the applet origin is distinct from the shell's:** the
bridge stays origin-restricted to the shell (`napplet.local`), so the cross-origin
applet still cannot reach it or read the shell DOM — it talks only via `postMessage`,
which the shell relays. Per-applet subdomains keep applets' storage isolated from one
another; CSP `connect-src 'none'` is unchanged; the app CSP was **tightened** to
`'self'` (it no longer grants the shell origin).
## Residual risks / future work
- **`allow-same-origin` is safe only while the applet origin ≠ the shell/bridge origin.**
A frame carrying *both* `allow-scripts` and `allow-same-origin` can strip its own
sandbox **iff it is same-origin with its embedder**; here it never is (applet on a
per-applet subdomain, shell on `napplet.local`), so it cannot. **Load-bearing
invariant — do not break:** never serve the applet on the shell origin, and never add
an applet origin (`*.napplet.local`) to the bridge's `addWebMessageListener` allowlist
(kept to `setOf(ORIGIN)`). Collapsing the two origins would hand the applet the bridge.
- **Persistent client storage is now a persistence/exfil surface.** The applet keeps
`localStorage`/`IndexedDB` across launches (origin-scoped, per applet, key-free and
isolated from other applets). A consented applet can build durable local state and,
combined with allow-always RESOURCE, a durable profile. Tie to the session-scoped-grant
proposal above; consider a "clear this applet's data" affordance.
- **Per-applet origin id.** `sha256(author:identifier)` is stable per applet and
collision-resistant; root/replaceable applets (kinds 15128/15129, no `d` tag) collapse
to one origin per author — fine, since there's one root per author. Changing the id
scheme later resets an applet's storage (new origin); acceptable.
- **Service workers are reachable but unwired.** With a real origin the applet *can* now
register a service worker; the host does not yet route SW fetches
(`ServiceWorkerControllerCompat`), so registration currently fails gracefully (a
warning, not a crash). If SW support is wired later, SW-originated fetches MUST go
through the same verified content server, never the network.
- **Coarse, persistent grants.** Non-payment capabilities persist as ALLOW_ALWAYS. A
consented napplet can still, thereafter, publish as you (RELAY), read your social graph
(IDENTITY), and make arbitrary network calls (RESOURCE) without re-prompting. The new
chrome + toasts make this *visible*, but the model is still allow-forever. **Proposed:**
a session-scoped grant ("Allow while open", cleared on close) in `GrantState` +
consent dialog, defaulted for RESOURCE/RELAY; and per-origin consent for cross-origin
`resource.bytes` https fetches.
- **`resource.bytes` as exfil channel.** Once RESOURCE is allow-always, the applet can
encode data into arbitrary https URLs through the Tor proxy. Tor hides the IP, not the
payload. Tie to the per-origin/session proposal above.
- **`'unsafe-inline'` in app `script-src`.** Required to inject the shim; the applet is
the author's own (content sha256-pinned), so it's not an escalation. `connect-src
'none'` remains the real boundary. Residual, accepted.
- **Trust pivots on the author key.** A compromised author key lets an attacker push a
new *signed* manifest and own the app — expected Nostr trust model. Aggregate `x` hash
is enforced when present (only *recommended* by the spec); per-path hashes always
protect.
- **Sandbox isolation is now structural.** The `:napplet` runtime
(`NappletHostActivity`, content server, IPC, key actions) lives in its own
`:nappletHost` module that depends only on `:commons` + `:quartz` — so it is
*compile-time incapable* of importing `Amethyst`/`LocalCache`/`Account`. The
broker-side (signer, gateways, `NappletLaunchRegistry`) stays in `:amethyst`;
the activity binds the broker service by class-name string and the two halves
communicate only over Messenger IPC.
- **Launch-token lifecycle.** Tokens are capped (LRU, 128) rather than explicitly
unregistered on sandbox close (the sandbox is a separate process and can't reach the
main-process registry). A long-backgrounded napplet whose token was evicted would need
relaunch. Acceptable; revisit if it bites.
@@ -0,0 +1,55 @@
# Napplet NAP domains: theme, notify, inc
> **Status:** shipped — `NappletCapability` has `THEME`/`NOTIFY`/`INC` and `NappletIncBus` is wired into `NappletBrokerService`.
> _Audited 2026-06-30._
Date: 2026-06-23
Status: in progress
## Problem
Real-world demo napplets (e.g. kehto/web's `apps/playground/napplets/*`) hard-gate
their own boot: each reads `window.napplet.shell.supports(<domain>)` for every
domain in its manifest `requires` and aborts ("unavailable") if any is missing.
**Every** kehto demo `requires: theme`; most also need `inc`; toaster needs
`notify`. Amethyst's `fromNapDomain` returns `null` for `theme/notify/inc/cvm`,
so `shell.supports()` is false for them and all demos fail at boot.
These are real NAP service domains (kehto ships reference handlers). We add the
three the demos need (theme, notify, inc); `cvm` is deferred (its own design).
## Wire contracts (verified against kehto reference services + demos)
- **theme** — `theme.get``theme.get.result { theme: { colors: { background, text, primary } } }`.
Optional host push `theme.changed { theme }` (we skip the push for v1; the
app theme rarely changes while a napplet is foreground). Read-only, **no consent**.
- **notify** — `notify.create { title, body }``notify.created { id }` (past-tense,
**not** the generic `.result`), `notify.list``notify.listed { notifications }`,
`notify.dismiss { notificationId }` fire-and-forget. Consent-gated (ask once).
Host shows a system notification + tracks a per-coordinate store for list/dismiss.
- **inc** — a topic pub/sub bus. `inc.emit { topic, args, payload }` (fire-and-forget)
delivers `inc.event { topic, payload }` to **other** subscribed napplet sessions
(no echo to the sender). `inc.subscribe`/`inc.unsubscribe` register interest.
Gated on the INC declaration at the router edge (like `identity.watch`), no
per-call consent. NOTE: Amethyst runs napplets **foreground-only, one at a time**,
so cross-napplet delivery is usually a no-op in practice — but the bus is correct
if/when multiple sessions overlap, and it lets the demos boot + emit without error.
## Capability mapping & consent
`NappletCapability` gains `THEME`, `NOTIFY`, `INC`; `fromNapDomain` maps the bare
domains. `requiresConsent` is false for `SHELL` and `THEME` (negotiation/cosmetic),
true otherwise. INC is authorized at the router (declared-only) and never reaches
the broker consent path.
## Touch points
- commons: `NappletCapability`, `NappletRequest`, `NappletResponse`,
`NappletBrokerCollaborators` (new gateways), `NappletBroker`,
`protocol/NappletProtocolJson` (decode + custom reply types + inc/theme pushes),
`NappletRequestRouter` (inc edge ops).
- amethyst: `gateways/AccountNappletGateways` (+ theme/notify gateways), an
app-wide `NappletIncBus`, and `NappletBrokerService` wiring (notify store + inc
push transport, like `NappletLiveSubscriptions`/`NappletIdentityWatch`).
Staged commits: (1) theme, (2) notify, (3) inc.
@@ -0,0 +1,183 @@
# Embedded napplet / nsite / browser tabs — final architecture
> **Status:** shipped — `EmbeddedTabLayer` and the `:nappletHost` module exist; embedded warm-tab + browser render paths are implemented (PR #3348).
> _Audited 2026-06-30._
**Date:** 2026-06-24
**Status:** Implemented on `claude/webview-menu-custom-url-ikrgbz` (PR #3348); needs on-device verification.
**Supersedes the rendering half of** `amethyst/plans/2026-06-19-napplet-sandbox-host.md`
(full-screen-only `NappletHostActivity` in `amethyst/androidMain`). The trust
model is unchanged and still governed by
`amethyst/plans/2026-06-22-napplet-nsite-security.md`.
## What changed since the sandbox-host plan
The 2026-06-19 design rendered a napplet/nsite **only** full-screen, in its own
`:napplet` Activity/task. This branch adds:
1. A second, **embedded** render path: warm bottom-bar "favorite" tabs that live
inside the main Amethyst window, swapped in place with no relaunch — built on
a cross-process `SurfaceControlViewHost` surface.
2. A **browser** host: the same keyless `:napplet` sandbox now also renders an
arbitrary user-typed URL (not just a verified-blob nsite/napplet), as both a
full-screen activity and an embedded tab.
3. A working **soft keyboard** inside the embedded surface (the cross-process
surface window cannot itself be an IME target).
4. The sandbox runtime extracted into its **own `:nappletHost` module** (depends
only on `:commons` + `:quartz`; cannot import `Amethyst`/`LocalCache`/`Account`).
5. **Per-site Tor / open-web** routing memory, and a startup **preloader** that
warms every bottom-bar favorite so the first tap is instant.
The keyless-sandbox guarantee is preserved throughout: keys live only in the main
process, every NIP-07 / `window.napplet` call is brokered + consent-gated, and the
embedded surface is z-ordered **below** the client window.
## Two render paths, one sandbox
| | Full-screen | Embedded tab |
|---|---|---|
| Container | `NappletHostActivity` / `NappletBrowserActivity` (`:napplet` task) | `SandboxedSdkView` in `EmbeddedTabLayer` (main window) |
| Surface | the Activity's own window | `SurfaceControlViewHost` shipped to the host `SandboxedSdkView` |
| Chrome (pull-down) | `NappletControlSheet` (native Android `View`s) | `TopControlSheet` (Compose) |
| Soft keyboard | the Activity's own window (native) | `RemoteImeView` proxy in the **main** window |
| Lifecycle | one session per task | many warm sessions, parked off-screen |
Both paths run the **same** `NappletHostService` / `NappletBrowserService` and the
same `shell.html` + `shim.js`; only the host/embedding differs.
## Persistent warm-surface layer (embedded)
`EmbeddedTabLayer` is mounted once in the app shell, below the drawer/dialogs. It
renders **every** warm session's `SandboxedSdkView` and keeps it attached:
- `EmbeddedTabHost` (object) owns the set of warm `sessions`, the active id, and
the reserved `contentBounds`. `setActive(id)` returns a token; `clearActiveIfOwner(token)`
only clears if the caller still owns it (so a fast tab A→B→A swap can't have B's
teardown clear A's just-set active id). `retainOnly(ids)` warm-keeps the
bottom-bar favorites (+ the momentarily-active tab) and evicts the rest.
- The active session is offset over the current tab's bounds; **inactive warm tabs
keep the same size and are merely shifted ~10 000 dp off-screen** (never resized
to 1 dp). Parking by translation rather than resize is what avoids the ~1 s black
flash on every tab switch (a resize forces a surface re-render).
- The surface is z-below, so Compose draws over it — that's how `TopControlSheet`
sits on top. Each surface is wrapped in `EmbeddedSurfaceTouchHolder` so a scroll
gesture isn't stolen by a host-side ancestor (the cross-process WebView can't
`requestDisallowInterceptTouchEvent` for itself).
`EmbeddedTabFactory` builds/acquires the per-app controller (`EmbeddedBrowserController`
/ `EmbeddedNappletController`, both `EmbeddedSurfaceController` + `EmbeddedImeBridge`)
and gates preloading on Tor (won't warm a Tor-routed site over clearnet while Tor is
still connecting).
## Per-session services (the multi-tab refactor)
`NappletHostService` / `NappletBrowserService` are **single shared instances** (same
bind `Intent`); per-tab state lives in a `NappletTab` / `BrowserTab` map keyed by the
client-stamped `KEY_SESSION_ID` (`NappletEmbedContract` / `NappletBrowserContract`).
Each tab carries its **own** reply `Messenger`, so broker responses + pushes route to
the right tab (the broker echoes `replyTo`). Critical correctness rules baked in:
- `contentServer` is `@Volatile` (read on the WebView worker thread in
`shouldInterceptRequest`, written on main).
- broker-reply delivery drops a reply whose tab was replaced (`tabs[id] !== tab`)
and wraps `postMessage` in `runCatching` (a torn-down WebView can't crash the relay).
- session close / `onDestroy` tears down **that tab's** content server + WebView only
(an earlier bug destroyed sibling tabs' WebViews → "open A, switch to B, back to A =
black").
## Embedded soft keyboard (IME proxy)
The embedded surface is a `SurfaceControlViewHost` window: in z-below mode it forwards
touch but **cannot be an IME target**, so a focused field would get no keyboard. The fix
mirrors Flutter's `TextInputPlugin`:
- `RemoteImeView` — an invisible, focusable `EditText` in the **main** app window takes
the keyboard. A real local `Editable` is the source of truth (the platform handles
composing regions, suggestions, autofill); edits are **coalesced across batch edits**
and the whole **editing state** (text + selection + composing) is shipped — not
individual ops. It flushes **synchronously** at the outermost `endBatchEdit` so a
compose-then-commit in one frame preserves the composing region.
- `shim.js` IME agent (gated on `window.__nappletImeProxy`) applies that state to the
focused field and synthesizes the matching DOM `input`/composition events so web
frameworks react as if typed natively. It is surrogate-pair-safe (emoji / CJK-supplement
never split into a lone surrogate) and supports `contenteditable` via Range-mapped char
offsets (in-place replacement, not a `textContent` overwrite), with selection-echo dedup.
- `EmbeddedTabLayer` shrinks the active surface to clear the keyboard using the **snapped**
`WindowInsets.imeAnimationTarget` (not the per-frame animated `ime`), so the expensive
cross-process surface resize happens once, not every animation frame.
State + ops cross over `MSG_IME_EVENT` / `MSG_IME_OP` (`EmbeddedImeBridge`). Full-screen
hosts set neither IME flag (they have a native keyboard).
## Per-site network routing
`WebUrlNetworkRegistry` (keyed by site host) and `NappletNetworkRegistry` (keyed by
`author:identifier`) remember whether a site routes through **Tor** (default) or the
**open web** — some servers reject Tor exits, so a user can opt one out and it must
survive relaunch. Both hydrate from DataStore asynchronously and now expose
`awaitReady()`; the preloader awaits hydration **before** its first routing decision so a
cold start can't route an open-web-pinned site through Tor. Live in the **main** process
only; the keyless sandbox never reads them (the launcher stamps the choice into the
launch intent).
## The two control-sheet twins
`TopControlSheet` (Compose, `:amethyst`, drawn in the main process over the z-below
surface) and `NappletControlSheet` (hand-built Android `View`s, `:nappletHost`, in the
keyless sandbox process) are deliberate twins: the sandbox module is Compose-free and
can't depend on `:amethyst`, so the composable can't be shared. They are kept visually
identical by hand — same 10 dp row rhythm, same muted-icon + framework `Switch` Tor row.
Both are a top-center pull-down (collapsed = a small grabber out of the corner where a
site puts its own avatar/menu): route over Tor, reload, "what it can access" (sandboxed
apps), open full screen.
## Bottom bar
Built-ins and favorites are one ordered list (`BottomBarEntry`, polymorphic with
`@SerialName("builtIn")` / `@SerialName("favorite")`). `UISharedPreferences.decodeBottomBarItems`
migrates the old persisted discriminator (the kotlinx default fully-qualified class name)
to the short names, falling back to `DefaultBottomBarEntries` — fixing the one-time bottom
bar reset the `@SerialName` change would otherwise have caused.
The **Browser** launcher (`BrowserScreen`) is an omnibox + the shared `FavoriteAppsGrid`;
each opened URL lands in its own full-screen `NappletBrowserActivity`. When the Browser is
reached from the drawer (pushed) rather than as a bottom-bar tab, its omnibox shows a back
arrow — the standard `nav.canPop()` rule (mirrors `NappletsTopBar`).
## File map (final)
**`:nappletHost`** (`com.vitorpamplona.amethyst.napplethost`, `:napplet` process):
`NappletHostService` / `NappletBrowserService` (per-session WebView hosts),
`NappletHostActivity` / `NappletBrowserActivity` (full-screen),
`NappletHostUiAdapter` / `NappletBrowserUiAdapter` (`SandboxedUiAdapter` for the embedded
surface), `NappletControlSheet` (native pull-down), `NappletContentServer` +
`NappletBlobHttp` + `NappletBlobCache` + `NappletBlobPrefetcher` (verified-blob serving,
Tor-routed, content-addressed), `NappletEmbedContract` / `NappletBrowserContract` /
`NappletHostContract` (Messenger wire keys), `NappletIpc`, `NappletKeyActions`,
`NappletWebViewInsets`.
**`:amethyst` embed** (`ui/screen/loggedIn/embed`): `EmbeddedTabLayer`, `EmbeddedTabHost`,
`EmbeddedTabFactory`, `EmbeddedTabChrome`, `EmbeddedSurfaceController`,
`EmbeddedSurfaceTouchHolder`, `EmbeddedImeBridge`, `RemoteImeView`, `TopControlSheet`,
`EmbeddedTabPreloader`, `TorToggleButton`.
**`:amethyst` main-process napplet** (`napplet/`): `NappletBrokerService`,
`NappletLauncher`, `NappletLaunchRegistry`, `WebUrlNetworkRegistry`,
`NappletNetworkRegistry`, `SandboxForegroundHold`, consent (`NappletConsent*`), gateways,
DataStore stores.
**`:amethyst` launchers/screens**: `BrowserScreen`, `FavoriteWebAppScreen`,
`FavoriteNappletScreen`, `FavoriteAppsScreen` + `FavoriteAppsGrid`.
**`:commons`**: `shell.html`, `shim.js` (`composeResources/files/napplet/`),
`FavoriteApp`/`FavoriteAppIcon`, `BottomBarEntry`.
## Residual / on-device verification
- All embedded-surface behavior (IME, scroll-gesture claiming, warm-keep across swaps, the
Tor shrink) needs emulator/device verification — `SurfaceControlViewHost` + the IME proxy
can't be unit-tested.
- The IME proxy runs a host-window `EditText`; it ships only editing **state** to the page,
never to any key material — the keyless-sandbox boundary is unaffected.
- Launch-token lifecycle, coarse persistent grants, and the `resource.bytes` exfil channel
remain as tracked in the 2026-06-22 security review.
@@ -0,0 +1,259 @@
# Embedded text selection — native-Android parity
> **Status:** shipped — Doc states core feature-complete; host-drawn selection (handles, magnifier, IME proxy) landed in `EmbeddedTabLayer`/`RemoteImeView`.
> _Audited 2026-06-30._
**Status:** core feature-complete. The working set landed in `fix(embed): IME typing +
host-drawn text selection for embedded surfaces` (commit `e0a2a9ab81`); subsequent
sessions added the magnifier, the `SelectionUiState` refactor, hybrid word+char
handle-extend, and a run of polish/bug fixes (below). **The one open platform bug —
full-screen round-trip kills selection paint — is now FIXED** (root cause was
process-global `pauseTimers()` + an attached `WebView.destroy()`; see that section).
**2026-06-26 fixes (branch `fix/embed-ime-selection`):**
- **No-blink word-select** — the overlay handles + toolbar blinked 23× on long-press
word-select; cause was the shim's selection-*reveal* scrolls (a `<textarea>` auto-
scrolling to show a forming/re-asserted range) tripping the hide-on-scroll path. The
shim now timestamps selection activity (`lastSelActivityAt`) and treats a scroll within
350 ms as a reveal-scroll (reposition, don't hide, don't re-arm the timer). Plus a
`RemoteImeView` range-lost debounce as a safety net. (#2, #3)
- **Page selection clears on field focus** — focusing a field left the page-text handles
+ Copy bar up (the shim's page `selectionchange` is muted once a field is focused), and
being z-above they STOLE the field handle's drag. Fixed: `focusin` emits `pagesel:false`
(+ resets the scroll state); host `ImeEvent.Focus` also drops the page overlay. (#2)
- **Caret handle drag** moved the loupe but not the caret — the unified `awaitEachGesture`
did `change.consume()` BEFORE `change.positionChange()`, and `positionChange()` returns
`Offset.Zero` once consumed, so `fp` never accumulated. Fixed with
`positionChangeIgnoreConsumed()` (also immune to the sandbox surface consuming the move). (#10)
- **Hybrid word+char handle-extend** completed (#5, below).
- **Full-screen round-trip corruption FIXED** (was the open bug; see section).
- **Embed WebViews follow the APP theme** — separate from selection, but same surfaces:
see `embed-webview-prefers-color-scheme-limitation` memo / `EmbedWebViewTheme.kt`.
## Why we draw selection ourselves
Editable fields inside an embedded napplet/nsite/browser tab live in the keyless
`:napplet` process and render through `SurfaceControlViewHost` /
`SandboxedSdkView` (privacy-sandbox UI). A WebView rendered into an off-window
surface like this **cannot host the soft keyboard and cannot present Chrome's
own text-selection UI** (handles, the floating action-mode toolbar, the
magnifier). Chrome detects it has nowhere to put that UI and collapses the
selection to the focus endpoint.
So, exactly like Flutter's `TextInputPlugin` did for its virtual-display era, we
relay editing to the main process: an invisible `EditText` (`RemoteImeView`)
hosts the keyboard, `shim.js` mirrors DOM selection/caret geometry out over the
Messenger channel, and `EmbeddedTabLayer` draws the selection UI in Compose on
top of the surface. Everything we want for parity, we draw — the platform gives
us nothing here.
## What native Android gives a text field (the parity target)
This is the full feature inventory we are cloning, with activation/deactivation
rules, so we can check off coverage. Native impl lives in `android.widget.Editor`
(+ `SelectionActionModeHelper`, `android.widget.Magnifier`,
`PopupTouchHandleDrawable` on the Chrome side).
| # | Native feature | Activates | Deactivates | Our status |
|---|----------------|-----------|-------------|------------|
| 1 | **Insertion handle** (the teardrop "blob" under the caret) | tap in editable text; tap again to re-show | typing, scroll start, focus loss, ~4s inactivity timeout | ✅ `InsertionHandle`. **Native availability rule now matched (2026-06-25):** only shown when the field is NON-EMPTY (`Editor` gates the handle behind `text.length() > 0`, via `SelectionUiState.fieldHasText`) — fixes it popping up on focus of an empty box; hides on typing (`onEdited`), scroll (`scrolling`), focus loss, and ~4s inactivity (`hideCaret` timeout), re-showing on the next tap — via an explicit `ime.carettap` shim signal (DOM `click`), so a tap that doesn't move the caret still re-shows it. Device-verified. |
| 2 | **Selection handles** (asymmetric left/right teardrops) | long-press word, double-tap word, drag-extend | tap-collapse, typing, new selection | ✅ `SelectionHandle(isStart)` + drag-to-extend, for BOTH plain page text (`pageExtend`) AND in-field `<input>`/`<textarea>` selections (`fieldExtend`, 2026-06-25). The shim reports the selection's caret feet (`sx/sb`,`ex/eb`, flagged `rng`) via the same mirror-div as the caret; the host holds the range geometry separately so Chrome's transient collapse-to-caret (the re-assert fight) doesn't yank the handles to the field edges. **Tap-to-collapse (2026-06-25):** a single tap inside a selection dismisses it to a caret at the tapped offset + insertion handle — the shim's `click` handler collapses explicitly via `offsetFromPoint` (off-window Chrome doesn't do it itself). Device-verified. |
| 3 | **Floating toolbar** (Cut/Copy/Paste/Select-All/Share/…) | selection made, or tap insertion handle (Paste/Select-All) | scroll/fling (hides, returns on settle), handle drag (hides), tap-collapse | ⚠️ `EmbeddedSelectionToolbar` (Cut/Copy/Paste/Select-All). **Hide-during-handle-drag ✅ device-verified.** Hide-during-scroll via `SelectionUiState.scrolling` (shim `ime.scroll` + re-report on settle). **2026-06-26: the scroll path was hardened** — selection-*reveal* scrolls (forming/re-asserting a range auto-scrolls a `<textarea>`) are no longer treated as user scrolls (they blinked the overlays); the shim guards them via `lastSelActivityAt` and the hide self-heals instead of re-arming. (User content-scroll-hide still wants a clean on-device pass.) Still missing: overflow, Share/Web-Search/process-text. |
| 4 | **Magnifier / loupe** (the zoom bubble above the finger while dragging a handle or the caret) | finger down + moving on a handle or the caret | finger up | ✅ **Built (2026-06-25).** `Magnifier` bubble in [EmbeddedMagnifier.kt] follows the dragged caret/selection handle, showing live magnified page pixels captured in the `:napplet` provider and shipped over IPC ([EmbeddedMagnifierProbe], option B). Both embed paths wired (browser + napplet); verified on device for the browser path. Capture Y is locked to the caret/selection line (X follows the finger). Possible further polish: RGB_565 to cut encode, themed crosshair, clamp capture X to the line so a fast drag past EOL doesn't show blank. |
| 5 | **Word-granularity long-press** then char-extend | long-press | — | ✅ HYBRID word+char in-field handle-extend (2026-06-25, completed): `fieldExtend` keeps per-drag state (`fieldDragWordEnd`/`fieldDragWordStart`, reset on a >250ms gap or edge switch). The drag baselines at the current selection edge; sweeping PAST that word's far boundary snaps to the next whole word (`wordEndAt`/`wordStartAt`), while moving within/back from the furthest-reached word gives CHARACTER precision — so you can fine-tune to a single character (the previously-missing "then char" mode). Page-text extend stays char (no offset model). |
| 6 | **Double-tap = word, long-press = word, (triple-tap/drag = paragraph)** | tap count | — | ✅ double-tap + long-press both select a word (2026-06-25). Chrome word-selects on the 2nd tap, then abandons it by collapsing to the end (off-window quirk); the host re-assert restores it. The shim `click` handler DEFERS its tap-to-collapse ~300ms and the real `dblclick` cancels that timer, so the word selection survives (a timing-only guard was flaky ~40%). Triple-tap/paragraph not done. |
| 7 | **Smart selection / entity expansion** (`TextClassifier`: phone, URL, address, date → entity actions in toolbar) | selection lands on an entity | — | ❌ not built (low priority) |
| 8 | **Drag selected text** (long-press a selection → drag-and-drop to move) | long-press on existing selection | drop | ❌ not built (low priority) |
| 9 | **Auto-scroll while dragging to a viewport edge** | handle dragged near top/bottom edge | finger leaves edge / up | ✅ works (2026-06-25, user-confirmed). The drag driver (`onMagnify`) detects the finger in the surface's top/bottom edge zone and sends `ime.autoscroll`; the shim scrolls the textarea (else the window) and re-reports geometry, flagged so the hide-on-scroll path doesn't fire. Scrolls per drag-move in the edge zone (not on a perfectly-held finger). **Fixed alongside:** the nav drawer's left-edge swipe was hijacking the edge drag — `EmbeddedSelectionDrag.dragging` (set by `onMagnify`) now suspends the drawer's `gesturesEnabled` while a handle is dragged. |
| 10 | **Caret snapping to character boundaries** | always during caret/handle drag | — | ✅ via `offsetFromPoint` binary search + Y-clamp. **2026-06-26 fix:** the insertion-handle drag stopped moving the caret (loupe showed, caret frozen) — the unified `awaitEachGesture` consumed the pointer change BEFORE reading `positionChange()`, which returns `Offset.Zero` once consumed, so the accumulated finger position never advanced. Now reads `positionChangeIgnoreConsumed()` first. |
| 11 | **Themed handle/caret drawables + blink** | always | — | ✅/⚠️ The host-drawn handles use `colorScheme.primary` — which IS the native `textSelectHandle`/accent color — so the handle drawables are themed (the actionable part). The caret bar + selection-highlight are drawn by Chrome inside the off-window surface: the caret already blinks natively, and theming its color/the highlight would mean injecting CSS into arbitrary third-party pages (intrusive; `::selection` was already ruled out as non-painting), so those are intentionally left to Chrome. |
| 12 | **Insertion-handle Paste/Select-All mini-popup** | tap the insertion handle | tap elsewhere | ✅ built (2026-06-25). Tapping the bare insertion handle toggles a Paste/Select-All bar above the caret (`SelectionUiState.insertionPopup`, toggled from the handle's unified tap/drag gesture); tap-elsewhere/typing/blur/selection/scroll dismiss it. Fixed a latent bug: toolbar items now consume the *down* (not just the up) so the tap doesn't bleed through to the surface and blur the field. Device-verified (Select-all selects all text, field stays focused). |
Legend: ✅ done · ⚠️ partial · ❌ missing.
### Activation/deactivation is the hard part
Most of the bugs we already fixed were activation-timing bugs (cursor-jumps-to-end,
collapse-on-tap, focus-transfer races). The remaining features each carry their
own state machine.
**Done (2026-06-25): `SelectionUiState`** (`SelectionUiState.kt`) now centralizes
what used to be scattered flags in `EmbeddedTabLayer` (`showInsertionHandle`,
`showSelectionToolbar`, `fieldGeometry`, `rangeFieldGeometry`, `pageSelection`).
It holds the three mutually-exclusive contexts (insertion caret / in-field range /
page-text range) plus the transient modifiers `dragging` and `scrolling`, and
exposes derived visibility (`insertionHandle`, `fieldHandles`, `fieldToolbar`,
`pageHandles`, `pageToolbar`) so the rules are expressed once:
- **toolbar hides while a handle is dragged** (`dragging`, set from the same
`OnMagnify` lifecycle that drives the loupe) — the dragged handle also hides its
own teardrop (the loupe stands in), like Android; the other handle stays.
- **all overlays hide while scrolling** (`scrolling`, from the shim's `ime.scroll`);
the shim re-reports geometry just before `active=false` so they reappear
repositioned.
Still emergent / TODO: insertion-handle auto-timeout, tap-to-re-show.
**Selection-blink fix (2026-06-25).** A field selection — especially in a `<textarea>`
flickered: off-window Chrome abandons the selection by collapsing the caret to an
endpoint every ~25 ms, and the FIELD re-assert round-tripped through the host EditText
(`RemoteImeView.onPageState``ime.set` → page), leaving a visible collapsed frame each
cycle. Fix: re-assert field selections **synchronously in the shim's `selectionchange`
handler** (mirror of the page-text path that never blinked) — `lastFieldRange`/`lastFieldAt`
tracked via `noteSel()`, and a collapse-to-endpoint within 1500 ms is reverted with `setSel`
(guarded, not re-reported) so it reverts before paint. The host re-assert stays as a
fallback. Device-verified: textarea selection is stable; input select still works.
## Priority order for parity work
1. **Magnifier (#4).** Biggest perceived gap. We already report caret/handle
geometry; the magnifier needs a *magnified pixel view of the surface* at the
drag point. Options:
- **A. Compose-side zoom of a surface snapshot. ❌ RULED OUT (spiked 2026-06-25).**
The surface is a `SurfaceControlViewHost` — we can't trivially `Bitmap`-grab a
remote surface from the main process. We spiked `PixelCopy.request(SurfaceView, …)`
against the live embedded surface (`SurfaceMagnifierProbe`, wired into
`EmbeddedTabLayer` behind `BuildConfig.DEBUG`, fired on field focus). The capture
target is the privacysandbox `ContentView extends SurfaceView` — the only real
child of `SandboxedSdkView` once the session opens. **Result on a clearly-painted
surface (1080×2088): every capture returns `ERROR_SOURCE_NO_DATA`** (center
region, repeated 3×). Cause: the WebView pixels live in a *child* `SurfaceControl`
reparented under the SurfaceView via `ContentView.setChildSurfacePackage(...)`; the
host SurfaceView's *own* buffer is never drawn into, so `PixelCopy` on the parent
reads an empty buffer. Host-side pixel capture of the sandboxed content is not
available. (A `PixelCopy.request(Window, …)` against the host window would also
miss it — the sandbox layer is a *separate* SurfaceControl z-ordered below the
window.)
- **B. Capture in `:napplet` and ship the loupe content. ✅ SPIKED & VIABLE
(2026-06-25).** Inside the keyless provider the WebView IS a real in-window view, so
`WebView.draw(Canvas)` into a software bitmap renders real DOM pixels. Spike added
`MSG_MAGNIFIER_REQUEST`/`MSG_MAGNIFIER_FRAME` to `NappletBrowserContract`:
`NappletBrowserService.onMagnifierRequest` draws a zoomed slice
(`canvas.scale(zoom); translate(-(cx-box/2), -(cy-box/2)); webView.draw(canvas)`),
PNG-encodes it, and ships the bytes back; `EmbeddedBrowserController` (now also an
`EmbeddedMagnifierProbe`) requests on focus and `EmbeddedTabLayer` logs the result.
**10-frame burst, 160px source × 1.5× zoom → 240×240 PNG, center `#FF111111`
(real opaque content):** provider draw 0.71.2 ms steady (≈5 ms cold), provider
total draw+PNG 34 ms steady (≈12 ms cold), client round-trip 48 ms steady
(occasional ~18 ms), payload 815 KB (far under the 1 MB Binder limit). Comfortably
within a frame budget if throttled to ~30 fps.
**✅ Real loupe shipped (2026-06-25).** `MagnifierUiState` + `Magnifier`
(`EmbeddedMagnifier.kt`); the caret/selection handles call an `OnMagnify` callback
on drag start/move/end; `EmbeddedTabLayer` tracks the drag point, throttles capture
requests to one in flight (100 ms timeout), decodes each `MagnifierFrame` to an
`ImageBitmap`, and floats the bubble above the finger (clamped, flips below near the
top). Capture mirrored onto BOTH embed paths (browser:
`NappletBrowserContract`/`NappletBrowserService`/`EmbeddedBrowserController`;
napplet: `NappletEmbedContract`/`NappletHostService`/`EmbeddedNappletController`).
Verified on device (browser): drag → bubble shows live magnified "ello world" with
the caret, centered on the line → follows finger → hides on release. The dead
Option-A probe (`SurfaceMagnifierProbe`) was removed. **Polish done (2026-06-25):**
capture Y is locked to the authoritative caret/selection line (the handles pass a
`lineHalfPx` so the box centers on the line, not the finger or the caret foot); X
still follows the finger. Remaining nice-to-haves: RGB_565/raw to cut PNG encode,
themed crosshair, clamp capture X to the line so a fast drag past EOL isn't blank,
reuse one off-screen bitmap.
- **Gesture-routing caveat (found during the spike).** The host-drawn handles'
drag is fragile: the sandbox `ContentView.onTouchEvent` always returns `true`, so
via Compose's `AndroidView` interop it consumes the drag-move pointer and cancels
the overlay handle's `detectDragGestures` (synthetic `adb` drags on the handle
never produced an `onDrag`). The magnifier trigger should ride the existing
caret/selection-move path (which already round-trips through the shim), not a fresh
Compose drag layered over the surface.
2. **Toolbar state rules (#3 hide-during-drag/scroll) + insertion-handle popup
(#12).** Pure Compose/state work, no platform unknowns. Do alongside the
`SelectionUiState` refactor.
3. **Handle inactivity timeout + tap-to-re-show (#1).** Small.
4. **Double-tap-to-select (#6)** and **word-granularity drag (#5).**
5. **Auto-scroll (#9), themed drawables/blink (#11).**
6. Defer: smart selection (#7), drag-to-move (#8).
## ✅ FIXED — full-screen round-trip corrupted the embedded WebViews (2026-06-26)
**Symptom (was).** Open an embedded field's page in its own full-screen activity,
then `back` to the embedded version. From then on **every** embedded surface in the
`:napplet` process was broken — and it was far more than the selection highlight: DOM
reads returned empty (a field that visibly showed text reported `value == ""`, so typing
prepended at offset 0 and backspace did nothing), DNS died (`ERR_NAME_NOT_RESOLVED`), the
selection highlight stopped painting, and IME broke. The page showed a stale last frame
over a functionally-dead renderer.
**Root cause — two process-global defects in the full-screen hosts** corrupting the
shared multiprocess WebView state the embedded surfaces rely on. (Diagnosed with temporary
logging: page console → logcat, all `onReceivedError`/`onReceivedHttpError`, and the shim's
focused-field type/caret. The user's own insight — "the activity is gone but the service
doesn't come back; am I using something from the activity?" — pointed straight at it.)
1. **`pauseTimers()`/`resumeTimers()` are PROCESS-GLOBAL** (they pause JS, layout and
parsing timers for *every* WebView in the process). `NappletBrowserActivity` /
`NappletHostActivity` `onPause`/`onResume` and `NappletHostService`'s embed
pause/resume all called them on their own lifecycle — so returning from full-screen
*froze* the embedded surfaces, which had no resume of their own. **Fix:** removed ALL
process-global timer calls; rely only on per-WebView `onPause()`/`onResume()` (which
pause just that surface's JS/DOM — still meets the napplet background-security goal).
2. **`WebView.destroy()` while still attached to the window** corrupts the shared
multiprocess renderer (`cr_AwContents: "WebView.destroy() called while WebView is still
attached to window"`). **Fix:** `stopLoading()` + `(parent as ViewGroup).removeView(...)`
before `destroy()` in both full-screen activities' `onDestroy`.
Device-verified: the round-trip no longer corrupts the embeds. This supersedes the old
"recreate the session on return" hypothesis and the ruled-out attempts (`::selection` CSS,
surface resize, focus-cycle, `MSG_WAKE`) — none were the real cause.
## ✅ Embed WebViews follow the app theme (2026-06-26, separate concern)
Not text-selection, but the same off-window surfaces: embedded (and full-screen) WebViews
rendered web content in the *device* theme, ignoring the app's DARK/LIGHT preference.
**Root cause:** WebView's dark decision (`prefers-color-scheme` via algorithmic darkening)
reads the context's **theme** (`?android:attr/isLightTheme`), NOT just `Configuration.uiMode`
— and the off-window `SurfaceControlViewHost` surface context carries neither. The old
`applyNightMode` used `UiModeManager.setNightMode` (permission-gated no-op). **Fix:** build
every WebView from `nightThemedContext()``ContextThemeWrapper(createConfigurationContext(
<night|day>), Theme.DeviceDefault.DayNight)` for the resolved theme — shared in
`nappletHost/.../EmbedWebViewTheme.kt`, used by both embed services + both full-screen
activities. The full debugging arc (config-only context fails; `setForceDark` gone at
targetSdk 37; `setApplicationNightMode` does nothing; it's the unthemed context, not the
process boundary) is in the `embed-webview-prefers-color-scheme-limitation` memo. Upstream
WebView is still buggy here (a cross-process SCVH WebView ignores `uiMode`); the
theme-wrapper is the app-side workaround.
## How to test
The on-device harness lives at **`tools/ime-test/`** (`index.html` + `README.md`).
It's a single page with an `<input>`, a `<textarea>`, and an on-page log that
timestamps focus/selection/input/composition events, **paint latency**,
long-tasks, and main-thread blocks — the instrumentation that pinned the erase,
caret-jump, and first-letter-freeze bugs, and exactly what we'll want when
profiling the magnifier.
Run it (full details in `tools/ime-test/README.md`):
1. `cd tools/ime-test && python3 -m http.server 8765`
2. Reach it: emulator → `http://10.0.2.2:8765`; USB device →
`adb reverse tcp:8765 tcp:8765` then `http://localhost:8765`.
3. Open that URL in the **in-app browser** to load it as an *embedded* tab. (Opening
the same URL full-screen and pressing `back` used to reproduce the
highlight/corruption bug — now fixed; it's still the regression test for it.)
Console log lines are tagged `[ImeDiag]` and surface in `adb logcat` (the
`:napplet` process owns the WebView console). This is a dev tool — nothing under
`tools/` ships, which is why those diagnostic strings are kept out of `src/`.
## Key files
- `commons/src/commonMain/composeResources/files/napplet/shim.js` — DOM bridge.
Geometry sources: `caretCoords` (287), `offsetFromPoint` (318), `fieldGeom`
(336), `reportState` (360), `pageGeom` (462), `sendPageSel` (470), `pageExtend`
(494). A magnifier built via option B would add a loupe-render here.
- `amethyst/.../embed/EmbeddedTabLayer.kt` — the Compose overlay. `InsertionHandle`
(557), `SelectionHandle` (496), `EmbeddedSelectionToolbar` (616),
`PageSelectionOverlay` (460), the `showInsertionHandle`/`showSelectionToolbar`
state to be folded into a `SelectionUiState`. Magnifier popup (option A) lands
here.
- `amethyst/.../embed/RemoteImeView.kt` — invisible host `EditText`; selection
re-assert + copy/cut/paste/select-all + edit callbacks.
- `amethyst/.../embed/EmbeddedImeBridge.kt``SelectionGeometry` (caret + handle
feet + viewport), `ImeEvent.{Focus,State,PageSelection}`, `parseSelectionGeometry`.
- `amethyst/.../{browser/EmbeddedBrowserController,favorites/EmbeddedNappletController}.kt`
— parse `ime.pagesel` + geometry off the Messenger channel.
- Context: `amethyst/plans/2026-06-19-napplet-sandbox-host.md`,
`2026-06-24-napplet-embedded-tabs.md`.
@@ -0,0 +1,66 @@
# Web-app naming overhaul (favorites / browser / app surfaces)
> **Status:** shipped — Renames applied — `WebAppScreen`, `NostrAppScreen`, `EmbeddedWebAppController`, `FavoriteAppsScreen` all present.
> _Audited 2026-06-30._
## Problem
The in-app "app" surfaces had colliding, sometimes inaccurate names:
- `software_apps` (NIP-89 native Android apps, an install-from-a-store flow) showed as
**"Apps"** — colliding with the in-app favorites, which showed as **"Favorite apps"**.
- The **host screens** that render a single web client / nSite / nApplet were named
`FavoriteWebAppScreen` / `FavoriteNappletScreen`. But they open *any* url/coordinate,
favorited or not — "Favorite" described how they happened to be reached (a pinned
bottom-bar tab), not what they are. And `FavoriteNappletScreen` also renders **nSites**
(website-mode), so "Napplet" was narrower than reality.
- Three vocabularies for two model cases: model `WebUrl`/`NostrApp`, route
`FavoriteWebApp`/`FavoriteNostrApp`, screen `FavoriteWebApp`/`FavoriteNapplet`.
## Taxonomy (decided with maintainer)
User-facing terms, now distinct:
| Concept | User-facing | What it is |
|---|---|---|
| Native app store | **App Store** | NIP-89 native Android apps you install off-device |
| Nostr web client | **Web app** | an `https://` client that runs in-app (WebView) |
| nApplet | **nApplet** | NIP-5D sandboxed JS app |
| nSite | **nSite** | NIP-5A static website |
| Pinned set | **Favorite** | a cross-cutting attribute (the star), *not* a screen |
Code axis (favorites / route / screen / embedded-controller layer): **`WebApp`** (url-based,
no nostr identity) and **`NostrApp`** (coordinate-based nSite *or* nApplet). The cross-process
sandbox infra (`napplet/`, `nappletHost/`, `NappletHostService`, `NappletEmbedContract`)
keeps **"Napplet"** — that process genuinely is the napplet host (it serves nSites in
website-mode too, but the host *is* the napplet runtime).
"Favorite" is reserved for the **grid of pinned apps** (`FavoriteAppsScreen` /
`Route.FavoriteApps`) and the star toggle — the only things that are actually about favorites.
## Renames
Routes: `FavoriteWebApp(url)``WebApp(url)`; `FavoriteNostrApp(coordinate)``NostrApp(coordinate)`.
Screens: `FavoriteWebAppScreen``WebAppScreen`; `FavoriteNappletScreen``NostrAppScreen`.
Controllers: `EmbeddedBrowserController``EmbeddedWebAppController`;
`EmbeddedNappletController``EmbeddedNostrAppController`.
Factory: `acquireBrowser`/`browserId``acquireWebApp`/`webAppId`;
`acquireNapplet`/`nappletId``acquireNostrApp`/`nostrAppId`.
Model: `FavoriteApp.WebUrl``FavoriteApp.WebApp`. Registry: `WebUrlNetworkRegistry`
`WebAppNetworkRegistry`.
Strings: `software_apps` "Apps" → "App Store"; `favorite_apps_empty` reworded to name
nApplet/nSite.
## Stable (do NOT change — persistence / wire compat)
- Favorite `id` prefixes `"url:"` / `"nostr:"` (persisted dedup + bottom-bar keys).
- DataStore names `"favorite_apps"`, `"weburl_network"`; serialized type tags `"url"` / `"nostr"`.
- `napplet/` + `nappletHost/` sandbox infra names and IPC contracts.
## Follow-ups (not in this pass)
- Move `NostrAppScreen` + `EmbeddedNostrAppController` out of the `ui/...favorites/`
package (they are no longer favorites-specific) into a host package alongside the web side.
- Recent nApplets / nSites (parallel to the browser's recent web apps), surfaced on the
discovery screens.
@@ -0,0 +1,80 @@
# nSite / nApplet favorite icons
> **Status:** shipped — `quartz/.../NappletIconPath.kt` and `amethyst/.../favorites/NappletFavoriteIcon.kt` are in tree.
> _Audited 2026-06-30._
**Date:** 2026-06-26
**Status:** implemented (pending on-device verification of the blob image-load path)
## Problem
When a user favorites a plain web app and pins it to the bottom nav, the generic globe
icon is replaced by the **site's favicon**. Favorited nSites (NIP-5A) and nApplets
(NIP-5D) did not get the same treatment — they fell back to the generic grid glyph.
## Why the webapp trick doesn't carry over
The webapp favicon is **captured live** from the WebView that loads the page
(`NappletBrowserActivity.onReceivedIcon` → IPC `MSG_RECORD_ICON`
`BrowserIconRegistry`, keyed by host). That works because, for a plain webapp, the site
**is** the WebView's main frame.
nSites/nApplets render differently: they always load inside a **cross-origin sandboxed
iframe** under a trusted shell document (`commons/.../composeResources/files/napplet/shell.html`,
`iframe.src = '__APP_ORIGIN__/'`). `WebChromeClient.onReceivedIcon` only reports the
**main frame's** favicon — i.e. the shell (`<title>Napplet</title>`, no icon), never the
applet's iframe. So the live-capture approach is structurally blind to the app's own
favicon here, and mirroring it would silently show nothing.
## Approach: derive the icon from the manifest's own bundled blobs
An nSite/nApplet ships its files as `path → sha256` (`path` tags). Its icon is almost
always one of those blobs (a conventional `/favicon.png`, `/icon.png`,
`/apple-touch-icon.png`, …). We already download + sha256-verify every manifest blob into
a shared, content-addressed cache (`NappletBlobCache` / `NappletBlobPrefetcher`,
Tor-routed). So the icon can be resolved from the manifest itself — no WebView, no iframe
problem, content-addressed and verifiable, on the same private network path as everything
else.
### Resolution priority (per favorite)
1. **Captured/bundled blob** (`iconModel`) — the conventional icon path picked from the
manifest's blobs, loaded from the verified cache as a `file://` model.
2. **Manifest `icon` tag** (`FavoriteApp.iconUrl`) — the publisher-declared icon URL
(already wired before this change).
3. **Type glyph** — grid (nostr app) / globe (web), already the fallback in
`FavoriteAppIcon`.
Blob beats the `icon` URL deliberately: the blob is verified and rides the site's
Tor-routed path, whereas a remote `icon` URL would be a clearnet fetch by Coil. Both still
beat the glyph.
## Changes
- **quartz** `nip5aStaticWebsites/NappletIconPath.kt` (new) — pure, unit-tested heuristic
that picks the best icon `PathTag` from a manifest's `path` tags (priority list of
conventional names + a loose raster fallback; prefers shallower paths; raster formats
over `.ico`/`.svg`). Tests in `NappletIconPathTest.kt`.
- **quartz** — `NappletManifest.iconBlob()` (covers nApplet kinds) and
`RootSiteEvent.iconBlob()` / `NamedSiteEvent.iconBlob()` (nSite kinds) delegate to it.
- **amethyst** `favorites/NappletFavoriteIcon.kt` (new) — `rememberNappletIconModel(coordinate)`:
re-resolves the live event from `LocalCache`, picks its icon blob, ensures it's in the
shared cache (prefetching on demand, off the composition thread), and returns a `file://`
Coil model. Returns null until the blob is on disk (icon appears on next recomposition).
- **amethyst** — `AppBottomBar` and `FavoriteAppsScreen` now resolve that model for
`FavoriteApp.NostrApp` and pass it as `iconModel`, exactly as they already did with the
captured favicon for `FavoriteApp.WebApp`.
`FavoriteApp` is unchanged (no persistence migration): the icon is resolved from the live
manifest at render time, consistent with the existing rule that a `NostrApp` favorite is
only usable while its event is resolvable in `LocalCache`.
## Follow-ups / not done
- **On-device verification** of the blob → Coil image load (the heuristic + wiring are
verified by unit tests + compilation; the actual image render needs a device).
- **HTML `<link rel="icon">` parsing.** The heuristic matches by conventional file name.
A future pass could fetch + parse the index blob to honor a non-conventional icon path.
- **`.svg` / `.ico` decoding.** Listed as low-priority candidates; if Coil can't decode
them the `FavoriteAppIcon` error fallback shows the glyph, so it's harmless but not
guaranteed to render.
@@ -21,9 +21,9 @@
package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuToken
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.service.cashu.CashuParser
import com.vitorpamplona.amethyst.ui.components.GenericLoadable
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
@@ -48,12 +48,12 @@ class CashuBTest {
assertEquals(2, parsed.proofs[0].amount)
assertEquals("407915bc212be61a77e3e6d2aeb4c727980bda51cd06a6afc29e2861768a7837", parsed.proofs[0].secret)
assertEquals("009a1f293253e41e", parsed.proofs[0].id)
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].C)
assertEquals("02bc9097997d81afb2cc7346b5e4345a9346bd2a506eb7958598a72f0cf85163ea", parsed.proofs[0].c)
assertEquals(8, parsed.proofs[1].amount)
assertEquals("fe15109314e61d7756b0f8ee0f23a624acaa3f4e042f61433c728c7057b931be", parsed.proofs[1].secret)
assertEquals("009a1f293253e41e", parsed.proofs[1].id)
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].C)
assertEquals("029e8e5050b890a7d6c0968db16bc1d5d5fa040ea1de284f6ec69d61299f671059", parsed.proofs[1].c)
}
}
@@ -68,18 +68,18 @@ class CashuBTest {
assertEquals(1, parsed[0].proofs[0].amount)
assertEquals("acc12435e7b8484c3cf1850149218af90f716a52bf4a5ed347e48ecc13f77388", parsed[0].proofs[0].secret)
assertEquals("00ffd48b8f5ecf80", parsed[0].proofs[0].id)
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].C)
assertEquals("0244538319de485d55bed3b29a642bee5879375ab9e7a620e11e48ba482421f3cf", parsed[0].proofs[0].c)
assertEquals(3, parsed[1].totalAmount)
assertEquals(2, parsed[1].proofs[0].amount)
assertEquals("1323d3d4707a58ad2e23ada4e9f1f49f5a5b4ac7b708eb0d61f738f48307e8ee", parsed[1].proofs[0].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[0].id)
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].C)
assertEquals("023456aa110d84b4ac747aebd82c3b005aca50bf457ebd5737a4414fac3ae7d94d", parsed[1].proofs[0].c)
assertEquals(1, parsed[1].proofs[1].amount)
assertEquals("56bcbcbb7cc6406b3fa5d57d2174f4eff8b4402b176926d3a57d3c3dcbb59d57", parsed[1].proofs[1].secret)
assertEquals("00ad268c4d1f5826", parsed[1].proofs[1].id)
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].C)
assertEquals("0273129c5719e599379a974a626363c333c56cafc0e6d01abe46d5808280789c63", parsed[1].proofs[1].c)
}
@Test()
@@ -93,11 +93,11 @@ class CashuBTest {
assertEquals(64, parsed[0].proofs[0].amount)
assertEquals("7a8dcf9b3e8a247ce339e7369e9b4a19f31eacb69d8b0c65daaeb72d1acb9ad3", parsed[0].proofs[0].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[0].id)
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].C)
assertEquals("03df591d261bcd176c69e3e833bcab5348ca31d218620492859303a1f5e874e9c7", parsed[0].proofs[0].c)
assertEquals(32, parsed[0].proofs[1].amount)
assertEquals("9d81c1a2616853ad8049cbcd1c7c247add83b373828620bac2fd7f3e5a58aceb", parsed[0].proofs[1].secret)
assertEquals("009bb23d3a912e4e", parsed[0].proofs[1].id)
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].C)
assertEquals("039e52c02141738e9dc278db91bf3b333a37d13d8413d5acfe77a6b859de0de806", parsed[0].proofs[1].c)
}
}
@@ -67,13 +67,16 @@ class NotificationFeedFilterModeOverrideTest {
private val client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
OkHttpWebSocket.Builder(
httpClient = {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
canDial = { true },
),
scope,
)
@@ -58,13 +58,16 @@ class ThreadDualAxisChartAssemblerTest {
val client =
NostrClient(
OkHttpWebSocket.Builder {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
OkHttpWebSocket.Builder(
httpClient = {
OkHttpClient
.Builder()
.followRedirects(true)
.followSslRedirects(true)
.build()
},
canDial = { true },
),
scope,
)
+65 -1
View File
@@ -105,7 +105,6 @@
android:supportsRtl="true"
android:theme="@style/Theme.Amethyst"
android:largeHeap="true"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config"
android:hardwareAccelerated="true"
android:localeConfig="@xml/locales_config"
@@ -402,6 +401,71 @@
android:name=".service.call.CallNotificationReceiver"
android:exported="false" />
<!-- Sandboxed napplet/nsite host. Runs in an isolated process that holds no keys. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:launchMode="singleTask"
android:theme="@style/Theme.Amethyst" />
<!-- Direct-WebView browser for a single web client. Runs in the isolated, keyless `:napplet`
process and hosts the WebView directly (not a streamed surface), so scroll/zoom/keyboard work
natively. adjustResize shrinks the window for the soft keyboard. Its own task/recents entry. -->
<activity
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity"
android:process=":napplet"
android:exported="false"
android:autoRemoveFromRecents="true"
android:documentLaunchMode="intoExisting"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|keyboard|uiMode|navigation|fontScale|density"
android:windowSoftInputMode="adjustResize"
android:theme="@style/Theme.Amethyst" />
<!-- Capability-consent dialog. Runs in the main process (the only side trusted to grant). -->
<activity
android:name=".napplet.NappletConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".napplet.NappletConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".napplet.NappletSignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Main-process broker: holds the signer and brokers capabilities for the sandbox. -->
<service
android:name=".napplet.NappletBrokerService"
android:exported="false" />
<!-- Embedded-browser provider: hosts the in-app browser WebView in the isolated, keyless
process and ships its rendered surface to the main app (SurfaceControlViewHost). -->
<service
android:name="com.vitorpamplona.amethyst.napplethost.NappletBrowserService"
android:process=":napplet"
android:exported="false" />
<!-- Embedded nsite/napplet provider: hosts the verified-blob WebView in the isolated, keyless
process and ships its surface to the main app, so a favorited nsite/napplet can render as
an in-app tab instead of taking over the screen. Same trust model as NappletHostActivity. -->
<service
android:name="com.vitorpamplona.amethyst.napplethost.NappletHostService"
android:process=":napplet"
android:exported="false" />
</application>
@@ -21,11 +21,36 @@
package com.vitorpamplona.amethyst
import android.app.Application
import android.content.ComponentCallbacks2
import android.os.Build
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabHost
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.LogLevel
import java.io.File
/**
* The Android [Application]. **Heads-up: this app runs in TWO OS processes**, and Android
* instantiates this same class in each of them:
*
* - **main** — the normal app (UI, account, signer, `LocalCache`, relay client). [instance]
* ([AppModules]) is built here in [onCreate].
* - **`:napplet`** — the sandboxed WebView host for NIP-5D napplets / NIP-5A nSites
* (`NappletHostActivity`, declared `android:process=":napplet"`). It holds **no** account or keys.
* [onCreate] early-returns here, so [instance] is **left unset** and any access throws.
*
* There is no per-process Application in Android — `android:name` is one class for the whole package —
* so the process-name guard ([isNappletSandbox]) is how the two are kept apart.
*
* **Processes don't share memory:** every `object`/companion/`static` (e.g. `LocalCache`,
* `NappletLaunchRegistry`) is a *separate copy per process*. Don't assume [instance] exists off the
* main process, and don't try to share state via a singleton across the boundary — use Messenger IPC.
*/
class Amethyst : Application() {
init {
Log.minLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.ERROR
@@ -37,11 +62,43 @@ class Amethyst : Application() {
private set
}
/**
* Android instantiates this single Application class in EVERY process (there's no per-process
* Application in the manifest). The sandboxed napplet host runs in `:napplet`, where we must not
* build [AppModules] — so this is computed once and gates every lifecycle callback that would
* otherwise touch the unset [instance].
*/
private val isNappletSandbox by lazy { isNappletSandboxProcess() }
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp") { "onCreate $this" }
// Application.onCreate runs in every process. The sandboxed napplet host
// (`:napplet`) must NOT build AppModules — that would load the account and
// construct the signer in the very process we keep secret-free. The host
// is self-contained (WebView + IPC), so we skip all app init there and
// leave `instance` unset; any accidental use fails fast.
if (isNappletSandbox) {
Log.d("AmethystApp") { "Skipping AppModules init in sandbox process" }
return
}
instance = AppModules(this)
// Hydrate the device-local favorite-apps list (main process only; the sandbox never reads it).
FavoriteAppsRegistry.init(this)
// Hydrate the device-local browser visit history (main process only; feeds the omnibox suggestions).
BrowserHistoryRegistry.init(this)
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// Hydrate the per-web-client Tor routing preferences so a site opted out of Tor (some reject Tor
// exits) starts on the open web without first flashing a failed Tor load.
WebAppNetworkRegistry.init(this)
// After-background foreground recycle: when the app returns to
// the foreground after spending more than ~5 s in the
// background, publish a network-change event so every active
@@ -66,9 +123,22 @@ class Amethyst : Application() {
instance.initiate(this)
}
/** True when this process is the isolated `:napplet` WebView host (see AndroidManifest). */
private fun isNappletSandboxProcess(): Boolean {
val name =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
getProcessName()
} else {
runCatching { File("/proc/self/cmdline").readText().substringBefore('\u0000').trim() }.getOrNull()
}
return name?.endsWith(":napplet") == true
}
override fun onTerminate() {
super.onTerminate()
Log.d("AmethystApp") { "onTerminate $this" }
// The sandbox process never built AppModules; `instance` is unset there.
if (isNappletSandbox) return
instance.terminate(this)
}
@@ -80,6 +150,20 @@ class Amethyst : Application() {
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
Log.d("AmethystApp") { "onTrimMemory $level" }
instance.trim()
// onTrimMemory IS delivered to the `:napplet` process on real devices, where `instance`
// was never initialized — guard so a trim callback can't crash the sandbox.
if (isNappletSandbox) return
instance.trim(level)
// Drop warm embedded tab sessions under genuine memory pressure (decision: keep warm until the
// user or Android reclaims them). Deliberately NOT on UI_HIDDEN/BACKGROUND — those fire on every
// backgrounding, and a pinned tab should survive that. Only on real pressure levels, R+ only.
val pressure =
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW ||
level == ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL ||
level == ComponentCallbacks2.TRIM_MEMORY_MODERATE ||
level == ComponentCallbacks2.TRIM_MEMORY_COMPLETE
if (pressure && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
EmbeddedTabHost.evictAll()
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst
import android.content.ComponentCallbacks2
import android.content.Context
import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
@@ -43,6 +44,9 @@ import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.CachedRichTextParser
import com.vitorpamplona.amethyst.service.cast.CastRegistry
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityManager
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
@@ -60,6 +64,7 @@ import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManager
import com.vitorpamplona.amethyst.service.okhttp.DualHttpClientManagerForRelays
import com.vitorpamplona.amethyst.service.okhttp.EncryptionKeyCache
import com.vitorpamplona.amethyst.service.okhttp.OkHttpWebSocket
import com.vitorpamplona.amethyst.service.okhttp.OnionLocationCache
import com.vitorpamplona.amethyst.service.okhttp.SurgeDns
import com.vitorpamplona.amethyst.service.okhttp.SurgeDnsStore
import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache
@@ -70,6 +75,7 @@ import com.vitorpamplona.amethyst.service.relayClient.CacheClientConnector
import com.vitorpamplona.amethyst.service.relayClient.RelayProxyClientConnector
import com.vitorpamplona.amethyst.service.relayClient.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoordinator
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
@@ -94,6 +100,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayLogger
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.stats.RelayReqStats
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CachingEventDecoder
import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.okhttp.OkHttpBitcoinExplorer
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
@@ -110,6 +117,7 @@ import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.CachingOnchainBackend
import com.vitorpamplona.quartz.nipBCOnchainZaps.chain.EsploraBackend
@@ -120,8 +128,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
@@ -148,6 +159,9 @@ class AppModules(
val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler)
private val _trimLevelEvents = MutableSharedFlow<Int>(extraBufferCapacity = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val trimLevelEvents = _trimLevelEvents.asSharedFlow()
// Pre-load both preference DataStores in parallel on IO threads.
// Both constructors use runBlocking internally, so starting them concurrently
// reduces total blocking time from (torPrefs + uiPrefs) to ~max(torPrefs, uiPrefs).
@@ -232,6 +246,11 @@ class AppModules(
// path on first lookup. Stored in cacheDir — pure perf data, OK if the OS evicts it.
val dnsStore = SurgeDnsStore(File(appContext.safeCacheDir(), SurgeDnsStore.FILE_NAME), surgeDns)
// Shared cache populated by OnionLocationInterceptor from any HTTP/WebSocket
// response carrying an Onion-Location header. Consulted by OnionUrlRewriteInterceptor
// on Tor-enabled clients to transparently redirect to .onion addresses.
val onionLocationCache = OnionLocationCache()
// manages all the other connections separately from relays.
val okHttpClients: DualHttpClientManager =
DualHttpClientManager(
@@ -250,6 +269,7 @@ class AppModules(
val profileOnly = settings?.localBlossomCacheProfilePicturesOnly?.value ?: false
master && !profileOnly && localBlossomCacheProbe.available.value
},
onionCache = onionLocationCache,
)
// Offers easy methods to know when connections are happening through Tor or not
@@ -424,6 +444,7 @@ class AppModules(
isMobileDataProvider = connManager.isMobileOrNull,
scope = applicationIOScope,
dns = surgeDns,
onionCache = onionLocationCache,
)
// Connects the INostrClient class with okHttp
@@ -479,8 +500,10 @@ class AppModules(
OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney)
}
// Provides a relay pool
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope)
// Provides a relay pool. The caching decoder skips re-parsing EVENT frames
// that arrive again via another subscription or relay (14-57% of frames in
// production measurements).
val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder())
// Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't
// see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough
@@ -512,6 +535,15 @@ class AppModules(
// Show messages from the Relay and controls their dismissal
val notifyCoordinator = NotifyCoordinator(client)
// Persists per-relay NIP-42 ALLOW/DENY overrides across app restarts.
val relayAuthPermissionStore by lazy {
DataStoreRelayAuthPermissionStore(appContext)
}
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -744,6 +776,18 @@ class AppModules(
sessionManager.loginWithDefaultAccountIfLoggedOff()
}
// One-time hygiene: remove per-account directories (MLS/Marmot stores) left behind
// by accounts that are no longer saved — account deletion historically didn't clean
// them up, so they leaked disk across every add/remove.
applicationIOScope.launch {
val keep =
LocalPreferences
.allSavedAccounts()
.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }
.toSet()
accountsCache.pruneOrphanAccountDirs(keep)
}
// forces initialization of uiPrefs in the main thread to avoid blinking themes
uiPrefs
@@ -765,6 +809,13 @@ class AppModules(
resourceCacheInit()
}
// Initialize napplet permission stores on an IO thread to avoid StrictMode violations
// when ConnectedAppsScreen first accesses them on the main thread.
applicationIOScope.launch {
nappletPermissionStore
signerPermissionStore
}
// registers to receive events
pokeyReceiver.register(appContext)
@@ -852,12 +903,71 @@ class AppModules(
accountsCache.clear()
}
fun trim() {
fun trim(level: Int) {
_trimLevelEvents.tryEmit(level)
applicationIOScope.launch {
// Backgrounding is a natural moment to flush the DNS cache.
dnsStore.save()
val loggedIn = accountsCache.accounts.value.values
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts())
trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts(), level)
// Trim in-process caches proportional to OS memory pressure.
//
// Background levels (app not visible, ordered highest-first so the when
// chain short-circuits at the right tier):
// COMPLETE (80) — at the bottom of the LRU list, kill imminent
// MODERATE (60) — system is hurting, neighbouring apps being killed
// BACKGROUND(40) — backgrounded, mild system pressure
// UI_HIDDEN (20) — just backgrounded, no pressure yet
//
// Foreground levels (app is active but system is low):
// RUNNING_CRITICAL (15), RUNNING_LOW (10)
//
// UI_HIDDEN fires on EVERY app switch. Don't clear CPU-heavy caches
// (Robohash SVG assembly, rich-text parsing) there — clearing them
// forces a full rebuild on every resume and causes visible jank.
when {
level >= ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> {
// Kill imminent: free everything.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(0)
CachedRobohash.trimToSize(0)
nip11Cache.trimToSize(0)
}
level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE -> {
// System under real pressure: clear images and most parsed state.
memoryCache.trimToSize(0)
CachedRichTextParser.trimToSize(50)
CachedRobohash.trimToSize(10)
nip11Cache.trimToSize(100)
}
level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> {
// Backgrounded with mild pressure: trim significantly.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(200)
}
level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> {
// Just backgrounded, no pressure yet: trim images (bitmaps are the
// largest allocations) but keep parsed-text and avatar caches warm
// so resuming is instant.
memoryCache.trimToSize(memoryCache.maxSize / 2)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> {
// Foreground, critically low memory.
memoryCache.trimToSize(memoryCache.maxSize / 4)
CachedRichTextParser.trimToSize(100)
CachedRobohash.trimToSize(20)
nip11Cache.trimToSize(200)
}
level >= ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> {
// Foreground, low memory.
memoryCache.trimToSize(memoryCache.maxSize / 2)
CachedRichTextParser.trimToSize(250)
CachedRobohash.trimToSize(50)
nip11Cache.trimToSize(500)
}
}
}
}
}
@@ -37,6 +37,58 @@ import kotlin.time.measureTimedValue
@Suppress("SENSELESS_COMPARISON")
val isDebug = BuildConfig.DEBUG || BuildConfig.BUILD_TYPE == "benchmark"
data class MemorySnapshot(
val heapUsedMb: Long,
val heapMaxMb: Long,
val nativeHeapUsedMb: Long,
val imageCacheUsedMb: Long,
val imageCacheMaxMb: Long,
val imageDiskUsedMb: Long,
val imageDiskMaxMb: Long,
val noteCount: Int,
val userCount: Int,
val addressableCount: Int,
val chatroomCount: Int,
val memoryClassMb: Int,
) {
val heapFraction: Float get() = heapUsedMb.toFloat() / heapMaxMb.coerceAtLeast(1L).toFloat()
}
fun collectMemorySnapshot(context: Context): MemorySnapshot {
val rt = Runtime.getRuntime()
val heapUsedMb = (rt.totalMemory() - rt.freeMemory()) / 1_048_576L
val heapMaxMb = rt.maxMemory() / 1_048_576L
val nativeHeapUsedMb = Debug.getNativeHeapAllocatedSize() / 1_048_576L
val app = Amethyst.instance
val imageCacheUsedMb = app.memoryCache.size / 1_048_576L
val imageCacheMaxMb = app.memoryCache.maxSize / 1_048_576L
val imageDiskUsedMb = app.diskCache.size / 1_048_576L
val imageDiskMaxMb = app.diskCache.maxSize / 1_048_576L
val activityManager: ActivityManager? = context.getSystemService()
val isLargeHeap = (context.applicationInfo.flags and ApplicationInfo.FLAG_LARGE_HEAP) != 0
val memoryClassMb =
activityManager?.let {
if (isLargeHeap) it.largeMemoryClass else it.memoryClass
} ?: 0
return MemorySnapshot(
heapUsedMb = heapUsedMb,
heapMaxMb = heapMaxMb,
nativeHeapUsedMb = nativeHeapUsedMb,
imageCacheUsedMb = imageCacheUsedMb,
imageCacheMaxMb = imageCacheMaxMb,
imageDiskUsedMb = imageDiskUsedMb,
imageDiskMaxMb = imageDiskMaxMb,
noteCount = LocalCache.notes.size(),
userCount = LocalCache.users.size(),
addressableCount = LocalCache.addressables.size(),
chatroomCount = LocalCache.chatroomList.size(),
memoryClassMb = memoryClassMb,
)
}
private const val STATE_DUMP_TAG = "STATE DUMP"
fun debugState(context: Context) {
@@ -28,6 +28,7 @@ import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.UiSettings
@@ -105,7 +106,10 @@ private object PrefKeys {
const val DEFAULT_DISCOVERY_FOLLOW_LIST = "defaultDiscoveryFollowList"
const val DEFAULT_POLLS_FOLLOW_LIST = "defaultPollsFollowList"
const val DEFAULT_PICTURES_FOLLOW_LIST = "defaultPicturesFollowList"
const val DEFAULT_NAPPLETS_FOLLOW_LIST = "defaultNappletsFollowList"
const val DEFAULT_NSITES_FOLLOW_LIST = "defaultNsitesFollowList"
const val DEFAULT_WORKOUTS_FOLLOW_LIST = "defaultWorkoutsFollowList"
const val DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST = "defaultGitRepositoriesFollowList"
const val DEFAULT_CALENDARS_FOLLOW_LIST = "defaultCalendarsFollowList"
const val DEFAULT_PRODUCTS_FOLLOW_LIST = "defaultProductsFollowList"
const val DEFAULT_SHORTS_FOLLOW_LIST = "defaultShortsFollowList"
@@ -123,6 +127,7 @@ private object PrefKeys {
const val DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST = "defaultBrowseEmojiSetsFollowList"
const val DEFAULT_COMMUNITIES_FOLLOW_LIST = "defaultCommunitiesFollowList"
const val DEFAULT_FOLLOW_PACKS_FOLLOW_LIST = "defaultFollowPacksFollowList"
const val DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST = "defaultAppRecommendationsFollowList"
const val ZAP_PAYMENT_REQUEST_SERVER = "zapPaymentServer" // legacy, kept for migration
const val NWC_WALLETS = "nwcWallets"
const val DEFAULT_NWC_WALLET_ID = "defaultNwcWalletId" // legacy, migrated into DEFAULT_PAYMENT_SOURCE_ID
@@ -151,7 +156,9 @@ private object PrefKeys {
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
const val HIDE_NIP_17_WARNING_DIALOG = "hide_nip24_warning_dialog" // delete later
const val ALWAYS_ON_NOTIFICATION_SERVICE = "always_on_notification_service"
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val SPLIT_NOTIFICATIONS_ENABLED = "split_notifications_enabled"
const val SHOW_MESSAGES_IN_NOTIFICATIONS = "show_messages_in_notifications"
// One-shot stamp: set once an account has gone through the notifications
// Global -> Selected (Curated) migration (or was created after it shipped).
@@ -179,6 +186,12 @@ object LocalPreferences {
private var currentAccount: String? = null
private val savedAccounts: MutableStateFlow<List<AccountInfo>?> = MutableStateFlow(null)
// Guards the one-time lazy population of [savedAccounts]. Without it, concurrent callers
// of savedAccounts() (e.g. the account-load path, the always-on notification service, and
// the orphan-dir sweep, all launched at startup) would each see a null value, run the IO
// read in parallel, and the migration branch could double-write ALL_ACCOUNT_INFO.
private val savedAccountsMutex = Mutex()
private val cachedAccounts: MutableMap<String, AccountSettings?> = mutableMapOf()
suspend fun currentAccount(): String? {
@@ -208,41 +221,46 @@ object LocalPreferences {
}
private suspend fun savedAccounts(): List<AccountInfo> {
if (savedAccounts.value == null) {
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
JsonMapper.fromJson<List<AccountInfo>>(it)
}
// Fast path: already populated, no lock needed.
savedAccounts.value?.let { return it }
if (!newSystemOfAccounts.isNullOrEmpty()) {
savedAccounts.emit(newSystemOfAccounts)
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
return savedAccountsMutex.withLock {
// Re-check under the lock: another coroutine may have populated it while we waited.
savedAccounts.value ?: loadSavedAccountsFromStorage().also { savedAccounts.emit(it) }
}
}
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
savedAccounts.emit(migrated)
edit {
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
}
private suspend fun loadSavedAccountsFromStorage(): List<AccountInfo> =
withContext(Dispatchers.IO) {
with(encryptedPreferences()) {
val newSystemOfAccounts =
getString(PrefKeys.ALL_ACCOUNT_INFO, "[]")?.let {
JsonMapper.fromJson<List<AccountInfo>>(it)
}
if (!newSystemOfAccounts.isNullOrEmpty()) {
newSystemOfAccounts
} else {
val oldAccounts = getString(PrefKeys.SAVED_ACCOUNTS, null)?.split(COMMA) ?: listOf()
val migrated =
oldAccounts.map { npub ->
AccountInfo(
npub,
encryptedPreferences(npub).getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false),
(encryptedPreferences(npub).getString(PrefKeys.NOSTR_PRIVKEY, "") ?: "").isNotBlank(),
false,
)
}
edit {
putString(PrefKeys.ALL_ACCOUNT_INFO, JsonMapper.toJson(migrated))
}
migrated
}
}
}
// it's always not null when it gets here.
return savedAccounts.value!!
}
fun accountsFlow() = savedAccounts
@@ -340,7 +358,27 @@ object LocalPreferences {
}
}
suspend fun setDefaultAccount(accountSettings: AccountSettings) {
/**
* Make [accountSettings] the current account, persisting + caching it. Returns the settings that
* actually became current — normally [accountSettings] itself, but see the downgrade guard below.
*/
suspend fun setDefaultAccount(accountSettings: AccountSettings): AccountSettings {
val npub = accountSettings.keyPair.pubKey.toNpub()
// Downgrade guard: adding a read-only npub for a pubkey we already hold a SIGNING account for
// must not clobber that account. Accounts dedup by npub, so saving fresh read-only settings
// here would overwrite the signing account's per-npub file — wiping its cached follow/relay/
// mute lists and flipping hasPrivKey off, which silently disables its push notifications. A
// signing account already does everything the read-only one would, so keep it and just make
// it current instead of degrading it.
if (!accountSettings.isWriteable()) {
val existing = loadAccountConfigFromEncryptedStorage(npub)
if (existing != null && existing.isWriteable()) {
setCurrentAccount(existing)
return existing
}
}
// Save the per-npub file before emitting onto the savedAccounts flow.
// Otherwise a collector (e.g. AlwaysOnNotificationServiceManager) can race in
// and call loadAccountConfigFromEncryptedStorage(npub) before NOSTR_PUBKEY is
@@ -348,9 +386,9 @@ object LocalPreferences {
// rest of the session — making every later switch to this account land on
// LoggedOff instead of LoggedIn.
saveToEncryptedStorage(accountSettings)
val npub = accountSettings.keyPair.pubKey.toNpub()
mutex.withLock { cachedAccounts.put(npub, accountSettings) }
setCurrentAccount(accountSettings)
return accountSettings
}
suspend fun allSavedAccounts(): List<AccountInfo> = savedAccounts()
@@ -388,7 +426,10 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPollsFollowList.value))
putString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultPicturesFollowList.value))
putString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNappletsFollowList.value))
putString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNsitesFollowList.value))
putString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultWorkoutsFollowList.value))
putString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultGitRepositoriesFollowList.value))
putString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCalendarsFollowList.value))
putString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultProductsFollowList.value))
putString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultShortsFollowList.value))
@@ -406,6 +447,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultBrowseEmojiSetsFollowList.value))
putString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultCommunitiesFollowList.value))
putString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultFollowPacksFollowList.value))
putString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, JsonMapper.toJson(settings.defaultAppRecommendationsFollowList.value))
val walletEntries = settings.nwcWallets.value.mapNotNull { it.denormalize() }
if (walletEntries.isNotEmpty()) {
@@ -466,7 +508,9 @@ object LocalPreferences {
putBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, settings.hideBlockAlertDialog)
putBoolean(PrefKeys.CALLS_ENABLED, settings.callsEnabled.value)
putBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, settings.alwaysOnNotificationService.value)
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, settings.splitNotificationsEnabled.value)
putBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, settings.showMessagesInNotifications.value)
// Any account that reaches a save has its notification filter in its
// post-split meaning, so stamp it as migrated. This keeps the one-shot
// Global -> Selected rewrite from ever touching it again and preserves a
@@ -581,7 +625,12 @@ object LocalPreferences {
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
val callsEnabled = getBoolean(PrefKeys.CALLS_ENABLED, true)
val alwaysOnNotificationService = getBoolean(PrefKeys.ALWAYS_ON_NOTIFICATION_SERVICE, false)
val defaultRelayAuthPolicy =
getString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, null)
?.let { runCatching { RelayAuthPolicy.valueOf(it) }.getOrNull() }
?: RelayAuthPolicy.IF_IN_MY_LIST
val splitNotificationsEnabled = getBoolean(PrefKeys.SPLIT_NOTIFICATIONS_ENABLED, false)
val showMessagesInNotifications = getBoolean(PrefKeys.SHOW_MESSAGES_IN_NOTIFICATIONS, true)
val hasDonatedInVersion = getStringSet(PrefKeys.HAS_DONATED_IN_VERSION, null) ?: setOf()
val dismissedPollNoteIds = getStringSet(PrefKeys.DISMISSED_POLL_NOTE_IDS, null) ?: setOf()
val viewedPollResultNoteIdsStr = getString(PrefKeys.VIEWED_POLL_RESULT_NOTE_IDS, null)
@@ -693,12 +742,50 @@ object LocalPreferences {
Log.d("LocalPreferences") { "Load account from file $npub - asyncs created" }
// Resolve every parallel parse into a local before constructing AccountSettings.
// Awaiting inside the 70-argument constructor expression below would place ~27
// suspension points in the middle of a single huge operand stack, forcing the
// coroutine state machine to spill/restore every partially-evaluated argument at
// each point. That bloats the generated method past the compiler's per-method
// instruction limit ("Method exceeds compiler instruction limit"). Awaiting into
// vals first keeps each suspension point at a statement boundary (near-empty
// operand stack) and leaves the constructor as straight-line, suspension-free code.
val nwcWalletsResolved = nwcWalletsLoaded.await()
val clinkDebitsResolved = clinkDebitsLoaded.await()
val defaultFileServerResolved = defaultFileServer.await()
val viewedPollResultNoteIdsResolved = viewedPollResultNoteIds.await()
val pendingAttestationsResolved = pendingAttestations.await()
val lastReadPerRouteResolved = lastReadPerRoute.await()
val latestUserMetadataResolved = latestUserMetadata.await()
val latestContactListResolved = latestContactList.await()
val latestDmRelayListResolved = latestDmRelayList.await()
val latestNip65RelayListResolved = latestNip65RelayList.await()
val latestSearchRelayListResolved = latestSearchRelayList.await()
val latestIndexRelayListResolved = latestIndexRelayList.await()
val latestRelayFeedsListResolved = latestRelayFeedsList.await()
val latestBlockedRelayListResolved = latestBlockedRelayList.await()
val latestTrustedRelayListResolved = latestTrustedRelayList.await()
val latestMuteListResolved = latestMuteList.await()
val latestPrivateHomeRelayListResolved = latestPrivateHomeRelayList.await()
val latestAppSpecificDataResolved = latestAppSpecificData.await()
val latestChannelListResolved = latestChannelList.await()
val latestCommunityListResolved = latestCommunityList.await()
val latestHashtagListResolved = latestHashtagList.await()
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
Log.d("LocalPreferences") { "Load account from file $npub - asyncs resolved" }
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServer.await(),
defaultFileServer = defaultFileServerResolved,
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
@@ -709,7 +796,10 @@ object LocalPreferences {
defaultDiscoveryFollowList = MutableStateFlow(followListPrefs.discovery),
defaultPollsFollowList = MutableStateFlow(followListPrefs.polls),
defaultPicturesFollowList = MutableStateFlow(followListPrefs.pictures),
defaultNappletsFollowList = MutableStateFlow(followListPrefs.napplets),
defaultNsitesFollowList = MutableStateFlow(followListPrefs.nsites),
defaultWorkoutsFollowList = MutableStateFlow(followListPrefs.workouts),
defaultGitRepositoriesFollowList = MutableStateFlow(followListPrefs.gitRepositories),
defaultCalendarsFollowList = MutableStateFlow(followListPrefs.calendars),
defaultProductsFollowList = MutableStateFlow(followListPrefs.products),
defaultShortsFollowList = MutableStateFlow(followListPrefs.shorts),
@@ -727,47 +817,50 @@ object LocalPreferences {
defaultBrowseEmojiSetsFollowList = MutableStateFlow(followListPrefs.browseEmojiSets),
defaultCommunitiesFollowList = MutableStateFlow(followListPrefs.communities),
defaultFollowPacksFollowList = MutableStateFlow(followListPrefs.followPacks),
nwcWallets = MutableStateFlow(nwcWalletsLoaded.await().first),
clinkDebitWallets = MutableStateFlow(clinkDebitsLoaded.await()),
defaultAppRecommendationsFollowList = MutableStateFlow(followListPrefs.appRecommendations),
nwcWallets = MutableStateFlow(nwcWalletsResolved.first),
clinkDebitWallets = MutableStateFlow(clinkDebitsResolved),
// Prefer the new unified default; migrate from the legacy NWC default;
// else fall back to the first configured source (NWC before debits).
defaultPaymentSourceId =
MutableStateFlow(
defaultPaymentSourceIdStr
?: nwcWalletsLoaded.await().second
?: clinkDebitsLoaded.await().firstOrNull()?.id,
?: nwcWalletsResolved.second
?: clinkDebitsResolved.firstOrNull()?.id,
),
hideDeleteRequestDialog = hideDeleteRequestDialog,
hideBlockAlertDialog = hideBlockAlertDialog,
hideNIP17WarningDialog = hideNIP17WarningDialog,
alwaysOnNotificationService = MutableStateFlow(alwaysOnNotificationService),
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
splitNotificationsEnabled = MutableStateFlow(splitNotificationsEnabled),
backupUserMetadata = latestUserMetadata.await(),
backupContactList = latestContactList.await(),
backupNIP65RelayList = latestNip65RelayList.await(),
backupDMRelayList = latestDmRelayList.await(),
backupSearchRelayList = latestSearchRelayList.await(),
backupIndexRelayList = latestIndexRelayList.await(),
backupRelayFeedsList = latestRelayFeedsList.await(),
backupBlockedRelayList = latestBlockedRelayList.await(),
backupTrustedRelayList = latestTrustedRelayList.await(),
backupPrivateHomeRelayList = latestPrivateHomeRelayList.await(),
backupMuteList = latestMuteList.await(),
backupAppSpecificData = latestAppSpecificData.await(),
backupChannelList = latestChannelList.await(),
backupCommunityList = latestCommunityList.await(),
backupHashtagList = latestHashtagList.await(),
backupGeohashList = latestGeohashList.await(),
backupEphemeralChatList = latestEphemeralList.await(),
backupTrustProviderList = latestTrustProviderList.await(),
lastReadPerRoute = MutableStateFlow(lastReadPerRoute.await()),
showMessagesInNotifications = MutableStateFlow(showMessagesInNotifications),
backupUserMetadata = latestUserMetadataResolved,
backupContactList = latestContactListResolved,
backupNIP65RelayList = latestNip65RelayListResolved,
backupDMRelayList = latestDmRelayListResolved,
backupSearchRelayList = latestSearchRelayListResolved,
backupIndexRelayList = latestIndexRelayListResolved,
backupRelayFeedsList = latestRelayFeedsListResolved,
backupBlockedRelayList = latestBlockedRelayListResolved,
backupTrustedRelayList = latestTrustedRelayListResolved,
backupPrivateHomeRelayList = latestPrivateHomeRelayListResolved,
backupMuteList = latestMuteListResolved,
backupAppSpecificData = latestAppSpecificDataResolved,
backupChannelList = latestChannelListResolved,
backupCommunityList = latestCommunityListResolved,
backupHashtagList = latestHashtagListResolved,
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIds.await()),
pendingAttestations = MutableStateFlow(pendingAttestations.await()),
backupNipA3PaymentTargets = latestPaymentTargets.await(),
backupCashuWallet = latestCashuWallet.await(),
backupNutzapInfo = latestNutzapInfo.await(),
viewedPollResultNoteIds = MutableStateFlow(viewedPollResultNoteIdsResolved),
pendingAttestations = MutableStateFlow(pendingAttestationsResolved),
backupNipA3PaymentTargets = latestPaymentTargetsResolved,
backupCashuWallet = latestCashuWalletResolved,
backupNutzapInfo = latestNutzapInfoResolved,
callsEnabled = MutableStateFlow(callsEnabled),
)
}
@@ -797,7 +890,10 @@ object LocalPreferences {
val discovery: TopFilter,
val polls: TopFilter,
val pictures: TopFilter,
val napplets: TopFilter,
val nsites: TopFilter,
val workouts: TopFilter,
val gitRepositories: TopFilter,
val calendars: TopFilter,
val products: TopFilter,
val shorts: TopFilter,
@@ -815,6 +911,7 @@ object LocalPreferences {
val browseEmojiSets: TopFilter,
val communities: TopFilter,
val followPacks: TopFilter,
val appRecommendations: TopFilter,
)
/**
@@ -849,7 +946,10 @@ object LocalPreferences {
discovery = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null), TopFilter.Global),
polls = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_POLLS_FOLLOW_LIST, null), TopFilter.Global),
pictures = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PICTURES_FOLLOW_LIST, null), TopFilter.Global),
napplets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NAPPLETS_FOLLOW_LIST, null), TopFilter.Global),
nsites = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_NSITES_FOLLOW_LIST, null), TopFilter.Global),
workouts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_WORKOUTS_FOLLOW_LIST, null), TopFilter.Global),
gitRepositories = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_GIT_REPOSITORIES_FOLLOW_LIST, null), TopFilter.Global),
calendars = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_CALENDARS_FOLLOW_LIST, null), TopFilter.Global),
products = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_PRODUCTS_FOLLOW_LIST, null), TopFilter.AroundMe),
shorts = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_SHORTS_FOLLOW_LIST, null), TopFilter.Global),
@@ -867,6 +967,7 @@ object LocalPreferences {
browseEmojiSets = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_BROWSE_EMOJI_SETS_FOLLOW_LIST, null), TopFilter.Global),
communities = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_COMMUNITIES_FOLLOW_LIST, null), TopFilter.AllFollows),
followPacks = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_FOLLOW_PACKS_FOLLOW_LIST, null), TopFilter.Global),
appRecommendations = parseTopFilterOrDefault(getString(PrefKeys.DEFAULT_APP_RECOMMENDATIONS_FOLLOW_LIST, null), TopFilter.Global),
)
private inline fun <reified T : Any> parseOrNull(value: String?): T? {
@@ -0,0 +1,154 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
private val Context.browserHistoryDataStore by preferencesDataStore(name = "browser_history")
/**
* One device-local visited site, keyed by full [url]. [visitCount]/[lastVisitedAt] drive frecency ranking
* in the omnibox suggestions.
*/
@Serializable
data class BrowserHistoryEntry(
val url: String,
val title: String,
val host: String,
val lastVisitedAt: Long,
val visitCount: Int,
)
/**
* The browser's visit history — the data behind the omnibox suggestions, alongside the user's favorites.
*
* **Only pages that actually loaded land here.** [record] is called from the `:napplet` browser host
* (relayed over IPC through `NappletBrokerService`) on a *successful* main-frame page-finish — never from
* the address bar as the user types — so misspelled/never-resolved hosts never pollute the list. Bounded
* to [MAX_ENTRIES] most-recent entries.
*
* Lives only in the **main process** (the launcher/omnibox consume it; the keyless `:napplet` sandbox
* never reads it). Same shape as [FavoriteAppsRegistry]: an authoritative in-memory [StateFlow] for
* synchronous Compose reads, with write-through persistence to a DataStore on a background scope.
*/
object BrowserHistoryRegistry {
private val KEY = stringPreferencesKey("history")
private const val MAX_ENTRIES = 500
private val _history = MutableStateFlow<List<BrowserHistoryEntry>>(emptyList())
val history: StateFlow<List<BrowserHistoryEntry>> = _history.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
@Volatile private var hydrated = false
/** Binds the app context and hydrates the on-disk list into [history]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val json = ctx.browserHistoryDataStore.data.first()[KEY]
val loaded = if (json != null) decode(json) else emptyList()
// Merge disk under anything already recorded this session (session wins, newest-first).
update { current -> dedupeNewestFirst(current + loaded) }
hydrated = true
}
}
/**
* Records a successful visit to [url], moving it to the front. An existing entry for the same URL is
* bumped (visit count +1, title refreshed if non-blank); otherwise a new entry is prepended.
*/
fun record(
url: String,
title: String,
) {
val host = OmniboxInput.hostOf(url) ?: url
val now = System.currentTimeMillis()
update { current ->
val existing = current.firstOrNull { it.url == url }
val entry =
if (existing != null) {
existing.copy(
title = title.ifBlank { existing.title },
host = host,
lastVisitedAt = now,
visitCount = existing.visitCount + 1,
)
} else {
BrowserHistoryEntry(url = url, title = title, host = host, lastVisitedAt = now, visitCount = 1)
}
(listOf(entry) + current.filterNot { it.url == url }).take(MAX_ENTRIES)
}
}
fun remove(url: String) = update { current -> current.filterNot { it.url == url } }
fun clear() = update { emptyList() }
private fun dedupeNewestFirst(list: List<BrowserHistoryEntry>): List<BrowserHistoryEntry> =
list
.sortedByDescending { it.lastVisitedAt }
.distinctBy { it.url }
.take(MAX_ENTRIES)
private inline fun update(transform: (List<BrowserHistoryEntry>) -> List<BrowserHistoryEntry>) {
val next = transform(_history.value)
if (next == _history.value) return
_history.value = next
persist(encode(next))
}
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.browserHistoryDataStore.edit { it[KEY] = json }
}
}
private fun encode(list: List<BrowserHistoryEntry>): String = JsonMapper.toJson(list)
private fun decode(json: String): List<BrowserHistoryEntry> =
try {
JsonMapper.fromJson<List<BrowserHistoryEntry>>(json)
} catch (e: Exception) {
Log.w("BrowserHistoryRegistry", "Failed to decode history", e)
emptyList()
}
}
@@ -0,0 +1,98 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import java.io.File
/**
* Device-local favicon store for browsed sites, keyed by host. Favicons are **captured from the WebView
* that already loaded the page** in the keyless `:napplet` browser host (where they ride the page's own —
* Tor-routed — network path) and relayed here as PNG bytes over IPC; this is the privacy-preserving
* alternative to the main app fetching `host/favicon.ico` itself, which would bypass Tor and leak the
* visit. Used to decorate favorite cards and omnibox suggestion rows.
*
* Lives only in the **main process**. Bytes are persisted as one small PNG per host under
* `filesDir/browser_icons`; the deterministic path means the only in-memory state is [keys] — the set of
* hosts that currently have an icon — which exists purely to drive Compose recomposition (and to keep
* `File.exists()` disk checks out of composition).
*/
object BrowserIconRegistry {
private const val DIR = "browser_icons"
private val _keys = MutableStateFlow<Set<String>>(emptySet())
/** Sanitized host keys that currently have a stored icon. Observe to recompose when an icon arrives. */
val keys: StateFlow<Set<String>> = _keys.asStateFlow()
@Volatile private var iconDir: File? = null
/** Binds the app context and indexes already-stored icons. Idempotent. */
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
iconDir = dir
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
}
/** Persists [bytes] as the favicon for [host] and marks it available. Called from the broker on IPC. */
fun record(
host: String,
bytes: ByteArray,
) {
val dir = iconDir ?: return
if (host.isBlank() || bytes.isEmpty()) return
val key = sanitize(host)
try {
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
/**
* A Coil model (`file://…`) for [host]'s favicon, or null when none is stored. Reads [keys] so callers
* that observe the flow recompose as icons arrive — pass [keys]'s value as a `remember` key.
*/
fun iconModelFor(host: String): String? {
val dir = iconDir ?: return null
val key = sanitize(host)
if (key !in _keys.value) return null
return "file://" + File(dir, key + PNG).absolutePath
}
// Hosts map to a flat, filesystem-safe filename. Collisions (two hosts → one key) only mean a shared
// icon file, which is harmless for a decoration.
private fun sanitize(host: String): String =
host
.lowercase()
.map { if (it.isLetterOrDigit() || it == '.' || it == '-') it else '_' }
.joinToString("")
.take(120)
private const val PNG = ".png"
}
@@ -0,0 +1,219 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.app.Activity
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplet.NappletLauncher
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* Turns a [FavoriteApp] back into a running app. The two cases map to the two launch paths in the
* codebase, nothing more:
*
* - [FavoriteApp.WebApp] → a full-screen direct-WebView
* [NappletBrowserActivity][com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity] (its own
* task/recents entry), so the web client owns the whole screen and scrolls/zooms natively.
* - [FavoriteApp.NostrApp] → re-resolve the live event from [LocalCache] by coordinate, read its
* `requires`/website-mode off the event, then hand to [NappletLauncher] (the sandboxed `:napplet`
* host). nsite vs napplet is decided *here*, from the event, never from stored state.
*
* A [FavoriteApp.NostrApp] whose event hasn't loaded yet can't launch; we surface that instead of
* failing silently.
*/
object FavoriteAppLauncher {
fun launch(
context: Context,
app: FavoriteApp,
) {
when (app) {
is FavoriteApp.WebApp -> launchUrl(context, app.url)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
}
}
/**
* Opens [url] full-screen in its own task, so back/recents treat it like a separate app. Uses the
* direct-WebView [NappletBrowserActivity] (page scrolls/zooms and the keyboard resizes natively),
* resolving the proxy port + this site's remembered Tor choice here in the main process. [preferTor]
* forces Tor (when available) regardless of the remembered choice — used for `.onion`, which only
* resolves over Tor.
*/
fun launchUrl(
context: Context,
url: String,
preferTor: Boolean = false,
) {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
val useTor = proxyPort > 0 && (preferTor || WebAppNetworkRegistry.useTor(url))
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
ThemeType.DARK -> "DARK"
ThemeType.LIGHT -> "LIGHT"
ThemeType.SYSTEM -> {
val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
val isFavorite = FavoriteAppsRegistry.isFavorite("url:$url")
val intent =
NappletBrowserActivity.intent(context, url, proxyPort, useTor, theme = theme, isFavorite = isFavorite).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
private fun launchNostrApp(
context: Context,
coordinate: String,
) {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
when (event) {
is RootNappletEvent ->
NappletLauncher.launch(context, event, event.pubKey, "")
is NamedNappletEvent ->
NappletLauncher.launch(context, event, event.pubKey, event.identifier())
is RootSiteEvent ->
NappletLauncher.launch(
context = context,
paths = event.paths(),
servers = event.servers(),
authorPubKey = event.pubKey,
identifier = "",
aggregateHash = null,
title = event.title() ?: "nsite",
requires = emptyList(),
profile = HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.launch(
context = context,
paths = event.paths(),
servers = event.servers(),
authorPubKey = event.pubKey,
identifier = event.identifier(),
aggregateHash = null,
title = event.title() ?: event.identifier(),
requires = emptyList(),
profile = HostProfile.WEBSITE,
)
else -> {
Log.w("FavoriteAppLauncher", "Favorited app not resolvable yet: $coordinate")
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
}
}
}
/**
* Builds the main-process-minted launch parameters for embedding the nsite/napplet at [coordinate]
* as an in-app tab (see `NappletHostService`). Returns null when the event isn't resolvable in
* [LocalCache] yet — the caller shows a loading state. nsite vs napplet (and website mode) is decided
* here from the live event, exactly as in [launchNostrApp].
*/
fun embedParams(
context: Context,
coordinate: String,
): Bundle? {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
return when (event) {
is RootNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
"",
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
HostProfile.NAPPLET,
)
is NamedNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
event.identifier(),
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
HostProfile.NAPPLET,
)
is RootSiteEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
"",
null,
event.title() ?: "nsite",
emptyList(),
HostProfile.WEBSITE,
)
is NamedSiteEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
event.identifier(),
null,
event.title() ?: event.identifier(),
emptyList(),
HostProfile.WEBSITE,
)
else -> null
}
}
/**
* The addressable coordinate `kind:pubkey:dtag` used to key an nsite/napplet favorite. Stored
* instead of the content hash so the favorite survives routine code/manifest updates.
*/
fun coordinateOf(event: Event): String {
val dTag =
when (event) {
is NamedNappletEvent -> event.identifier()
is NamedSiteEvent -> event.identifier()
else -> ""
}
return "${event.kind}:${event.pubKey}:$dTag"
}
}
@@ -0,0 +1,214 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import java.util.concurrent.ConcurrentHashMap
private val Context.favoriteAppsDataStore by preferencesDataStore(name = "favorite_apps")
/**
* The user's device-local list of [FavoriteApp]s — the single source of truth shared by the bottom
* bar, the Favorite Apps grid, and the browser launcher. Ordered (the user can reorder); de-duplicated
* by [FavoriteApp.id].
*
* Lives only in the **main process** (the launcher/UI consume it); the keyless `:napplet` sandbox never
* touches it. An in-memory [StateFlow] is authoritative for the session so Compose can observe it
* synchronously, with write-through persistence to a DataStore on a background scope. The list is
* stored as a single JSON array under one key (small, bounded, hand-curated data — no need for one key
* per entry).
*/
object FavoriteAppsRegistry {
private val KEY = stringPreferencesKey("favorites")
// Raw manifest event JSON for each favorited [FavoriteApp.NostrApp], keyed by its addressable
// coordinate. Cached so a pinned nsite/napplet resolves instantly on the next cold start — and
// offline — instead of waiting on a relay round-trip the way a [FavoriteApp.WebApp]'s URL never
// has to. The relay subscription that warms these favorites keeps the cache fresh.
private val MANIFESTS_KEY = stringPreferencesKey("manifests")
private val _favorites = MutableStateFlow<List<FavoriteApp>>(emptyList())
val favorites: StateFlow<List<FavoriteApp>> = _favorites.asStateFlow()
private val manifestCache = MutableStateFlow<Map<String, String>>(emptyMap())
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
// Hydration runs async on a background scope, so the user can add/remove before the disk list
// merges in. [removedBeforeHydration] tombstones any id removed in that window, so the merge can't
// resurrect a just-deleted favorite from disk.
@Volatile private var hydrated = false
private val removedBeforeHydration = ConcurrentHashMap.newKeySet<String>()
/** Binds the app context and hydrates the on-disk list into [favorites]. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
scope.launch {
val prefs = ctx.favoriteAppsDataStore.data.first()
val loaded = prefs[KEY]?.let { decode(it) } ?: emptyList()
// Don't clobber adds made in this session before hydration finished, and don't resurrect
// anything the user removed in that same window.
update { current -> (loaded.filterNot { it.id in removedBeforeHydration } + current).distinctBy { it.id } }
// Same race rules for the manifest cache: a cacheManifest() in this session wins over the
// disk copy, and a manifest whose favorite was removed pre-hydration must not come back.
val loadedManifests = prefs[MANIFESTS_KEY]?.let { decodeManifests(it) } ?: emptyMap()
updateManifests { current -> loadedManifests.filterKeys { "nostr:$it" !in removedBeforeHydration } + current }
hydrated = true
removedBeforeHydration.clear()
}
}
fun isFavorite(id: String): Boolean = _favorites.value.any { it.id == id }
/** Adds [app] to the end if not already present (by [FavoriteApp.id]). */
fun add(app: FavoriteApp) = update { current -> if (current.any { it.id == app.id }) current else current + app }
fun remove(id: String) {
if (!hydrated) removedBeforeHydration.add(id)
update { current -> current.filterNot { it.id == id } }
// Drop the cached manifest too — favorite ids for nsites/napplets are "nostr:<coordinate>".
if (id.startsWith("nostr:")) updateManifests { it - id.removePrefix("nostr:") }
}
/** The cached manifest event JSON for a favorited nsite/napplet [coordinate], or null if none. */
fun cachedManifest(coordinate: String): String? = manifestCache.value[coordinate]
/**
* Caches the raw manifest event [eventJson] for a favorited nsite/napplet [coordinate] so the next
* launch can resolve it instantly / offline. Write-through; no-ops when the JSON is unchanged.
*/
fun cacheManifest(
coordinate: String,
eventJson: String,
) = updateManifests { if (it[coordinate] == eventJson) it else it + (coordinate to eventJson) }
/** Replaces the whole list, e.g. after a drag-reorder. */
fun setOrder(newOrder: List<FavoriteApp>) = update { newOrder }
private inline fun update(transform: (List<FavoriteApp>) -> List<FavoriteApp>) {
val next = transform(_favorites.value)
if (next == _favorites.value) return
_favorites.value = next
persist(encode(next))
}
private inline fun updateManifests(transform: (Map<String, String>) -> Map<String, String>) {
val next = transform(manifestCache.value)
if (next == manifestCache.value) return
manifestCache.value = next
persistManifests(encodeManifests(next))
}
private fun persist(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[KEY] = json }
}
}
private fun persistManifests(json: String) {
val ctx = appContext ?: return
scope.launch {
ctx.favoriteAppsDataStore.edit { it[MANIFESTS_KEY] = json }
}
}
// --- Persistence DTO ------------------------------------------------------------------------
// A flat, type-tagged record so we serialize one concrete shape instead of relying on
// polymorphic (sealed) (de)serialization. Mapping to/from the sealed model lives here.
@Serializable
private data class Entry(
val type: String,
val ref: String,
val label: String,
val addedAt: Long,
val iconUrl: String? = null,
)
private fun encode(list: List<FavoriteApp>): String =
JsonMapper.toJson(
list.map {
when (it) {
is FavoriteApp.NostrApp -> Entry(TYPE_NOSTR, it.coordinate, it.label, it.addedAt, it.iconUrl)
is FavoriteApp.WebApp -> Entry(TYPE_URL, it.url, it.label, it.addedAt, it.iconUrl)
}
},
)
private fun decode(json: String): List<FavoriteApp> =
try {
JsonMapper.fromJson<List<Entry>>(json).mapNotNull { entry ->
when (entry.type) {
TYPE_NOSTR -> FavoriteApp.NostrApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
TYPE_URL -> FavoriteApp.WebApp(entry.ref, entry.label, entry.addedAt, entry.iconUrl)
else -> null
}
}
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode favorites", e)
emptyList()
}
private const val TYPE_NOSTR = "nostr"
private const val TYPE_URL = "url"
// --- Manifest cache persistence -------------------------------------------------------------
// Stored as a flat list of (coordinate, json) records under one key — same single-key, hand-curated
// shape as the favorites list, so we never serialize a raw polymorphic map.
@Serializable
private data class ManifestEntry(
val coordinate: String,
val json: String,
)
private fun encodeManifests(manifests: Map<String, String>): String = JsonMapper.toJson(manifests.map { ManifestEntry(it.key, it.value) })
private fun decodeManifests(json: String): Map<String, String> =
try {
JsonMapper.fromJson<List<ManifestEntry>>(json).associate { it.coordinate to it.json }
} catch (e: Exception) {
Log.w("FavoriteAppsRegistry", "Failed to decode favorite manifests", e)
emptyMap()
}
}
@@ -0,0 +1,67 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Pre-fetches the addressable events behind the user's favorited [FavoriteApp.NostrApp]s (nSites /
* nApplets) while a screen that can launch them is on screen.
*
* A favorite stores only the addressable coordinate `kind:pubkey:dtag`, never the event. The launch
* path ([FavoriteAppLauncher.launchNostrApp] / [FavoriteAppLauncher.embedParams]) re-resolves the live
* event from [LocalCache] at tap time, so a favorite whose event hasn't streamed in yet can't launch —
* the user gets the "isn't loaded yet" toast / unavailable tab. Before this preloader, the only thing
* that pulled those events into the cache was visiting the nsite/napplet feed (it subscribes by author),
* which is why opening that feed and coming back made a favorite suddenly launchable.
*
* This subscribes each favorited coordinate to the shared
* [EventFinder][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssembler]
* — the same lifecycle-aware loader [observeNote][com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote]
* uses — so the manifests fetch (via the author's outbox relays) as soon as the launcher opens and are
* already in [LocalCache] by the time the user taps. The loader drops each coordinate from its filter
* once the event arrives, so this is a one-shot fetch, not a standing feed.
*/
@Composable
fun PreloadFavoriteNostrApps(
apps: List<FavoriteApp>,
accountViewModel: AccountViewModel,
) {
val account = accountViewModel.account
// Reuse the same query-state instances across recompositions (the manager ref-counts by identity),
// recomputing only when the favorite list or account changes. Events that already loaded are still
// included; the loader simply skips them because their note already has an event.
val states =
remember(apps, account) {
apps
.filterIsInstance<FavoriteApp.NostrApp>()
.mapNotNull { LocalCache.checkGetOrCreateAddressableNote(it.coordinate) }
.map { EventFinderQueryState(it, account) }
}
LifecycleAwareKeyDataSourceSubscription(states, accountViewModel.dataSources().eventFinder)
}
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.favorites
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobCache
import com.vitorpamplona.amethyst.napplethost.NappletBlobPrefetcher
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
/** The chosen icon blob and the servers that hold it — resolved from the live manifest. */
private class IconBlob(
val path: PathTag,
val servers: List<String>,
)
/**
* A Coil model (`file://…`) for the app icon an nsite/napplet bundles in its own content — the verified,
* content-addressed blob the [NappletIconPath][com.vitorpamplona.quartz.nip5aStaticWebsites.NappletIconPath]
* heuristic picks from the manifest's `path` tags — or null when the manifest declares no such icon (or it
* hasn't downloaded yet). Used to decorate an nsite/napplet favorite the same way a captured favicon
* decorates a web favorite, except this rides the same Tor-routed, sha256-verified blob path as the rest of
* the site instead of a clearnet favicon fetch.
*
* Unlike a webapp's favicon, this can't be captured live: the applet runs in a cross-origin sandboxed
* iframe under the trusted shell, so `WebChromeClient.onReceivedIcon` only ever reports the shell's icon,
* never the applet's. Deriving it from the bundled blobs is the only path that sees the real icon.
*
* Observes the addressable note, so the icon resolves whenever the manifest arrives or updates in
* LocalCache — not only if it already happened to be cached at first composition (on a cold start the
* event streams in from relays a moment later). Returns null until the blob is on disk; the icon then
* appears on the next recomposition. All disk + network work runs off the composition thread. The blob is
* usually already cached (the browse/feed card prefetches every manifest blob, this one included); the
* on-demand fetch here just covers favorites whose card isn't currently on screen.
*/
@Composable
fun rememberNappletIconModel(coordinate: String): String? {
val context = LocalContext.current
// checkGetOrCreate returns null only for a malformed coordinate, so this early return is stable for a
// given coordinate (it never flips across recompositions, which would break composition structure).
val note = remember(coordinate) { LocalCache.checkGetOrCreateAddressableNote(coordinate) } ?: return null
val noteState by note
.flow()
.metadata.stateFlow
.collectAsStateWithLifecycle()
// Key on the event itself, not the NoteState wrapper: re-resolve only when the manifest actually
// changes, not on every unrelated metadata bump (a reaction/zap tracked on the note).
val event = noteState.note.event
val icon = remember(event) { resolveIconBlob(event) } ?: return null
var model by remember(icon.path.hash) { mutableStateOf<String?>(null) }
LaunchedEffect(icon.path.hash) {
withContext(Dispatchers.IO) {
val file = File(NappletBlobCache.dirFor(context.cacheDir), icon.path.hash.lowercase())
if (!file.isFile) {
val torPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
runCatching { NappletBlobPrefetcher.prefetch(listOf(icon.path), icon.servers, context.cacheDir, torPort) }
}
if (file.isFile) model = "file://" + file.absolutePath
}
}
return model
}
/** Asks each nsite/napplet event type for its bundled icon blob + the servers that hold it. */
private fun resolveIconBlob(event: Event?): IconBlob? =
when (event) {
is RootNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is NamedNappletEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is RootSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
is NamedSiteEvent -> event.iconBlob()?.let { IconBlob(it, event.servers()) }
else -> null
}
/**
* A Coil model (`file://…`) for the cached favicon of [url]'s host, or null when no favicon
* has been captured yet. The favicon is stored by [BrowserIconRegistry] at browse time (the
* WebView captures it in the sandboxed `:napplet` process); this composable just reads the cache.
*
* Early-returns null when [url] is blank or has no parseable host — this early return is stable
* for a given [url] (the host either always parses or never does), so composition structure is
* preserved across recompositions.
*/
@Composable
fun rememberWebAppIconModel(url: String): String? {
val host = remember(url) { OmniboxInput.hostOf(url) } ?: return null
val iconKeys by BrowserIconRegistry.keys.collectAsStateWithLifecycle()
return remember(host, iconKeys) { BrowserIconRegistry.iconModelFor(host) }
}
/**
* A Coil model for the bundled icon of an napplet or nsite identified by [author] and
* [identifier]. Tries the napplet manifest (kinds 15129 / 35129) first, then falls back to the
* nsite manifest (kinds 15128 / 35128). Returns null until the blob lands on disk.
*/
@Composable
fun rememberManifestIconModel(
author: String,
identifier: String,
): String? {
val nappletCoord =
remember(author, identifier) {
if (identifier.isEmpty()) "${RootNappletEvent.KIND}:$author:" else "${NamedNappletEvent.KIND}:$author:$identifier"
}
val nsiteCoord =
remember(author, identifier) {
if (identifier.isEmpty()) "${RootSiteEvent.KIND}:$author:" else "${NamedSiteEvent.KIND}:$author:$identifier"
}
return rememberNappletIconModel(nappletCoord) ?: rememberNappletIconModel(nsiteCoord)
}
@@ -57,6 +57,7 @@ import com.vitorpamplona.amethyst.model.localRelays.ForwardKind0ToLocalRelayStat
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.marmot.KeyPackageRelayListState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountHomeRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountOutboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.NotificationInboxRelayState
import com.vitorpamplona.amethyst.model.nip01UserMetadata.UserMetadataState
@@ -72,6 +73,7 @@ import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
@@ -397,6 +399,7 @@ class Account(
val appRecommendations = AppRecommendationsState(signer, cache, scope)
val oldBookmarkState = OldBookmarkListState(signer, cache, scope)
val bookmarkState = BookmarkListState(signer, cache, scope)
val gitRepositoryListState = GitRepositoryListState(signer, cache, scope)
val pinState = PinListState(signer, cache, scope)
val emoji = EmojiPackState(signer, cache, scope)
val ownedEmojiPacks = OwnedEmojiPacksState(signer, cache, scope)
@@ -414,6 +417,7 @@ class Account(
// Relay settings
val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope)
val outboxRelays = AccountOutboxRelayState(nip65RelayList, privateStorageRelayList, localRelayList, broadcastRelayList, scope)
val mineRelays = AccountMineRelayState(nip65RelayList, privateStorageRelayList, localRelayList, proxyRelayList, scope)
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
@@ -425,6 +429,8 @@ class Account(
scope = scope,
assembler = cashuWalletFilterAssembler(),
outboxRelaysFlow = outboxRelays.flow,
inboxRelaysFlow = notificationRelays.flow,
dmRelaysFlow = dmRelays.flow,
settings = settings,
okHttpClient = okHttpClientForMoney,
)
@@ -504,6 +510,7 @@ class Account(
followsRelays = defaultGlobalRelays.flow,
blockedRelays = blockedRelayList.flow,
proxyRelays = proxyRelayList.flow,
mineRelays = mineRelays.flow,
relayFeeds = relayFeedsList.flow,
caches = feedDecryptionCaches,
signer = signer,
@@ -532,9 +539,18 @@ class Account(
val livePicturesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultPicturesFollowList)
val livePicturesFollowListsPerRelay = OutboxLoaderState(livePicturesFollowLists, cache, scope).flow
val liveNappletsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNappletsFollowList)
val liveNappletsFollowListsPerRelay = OutboxLoaderState(liveNappletsFollowLists, cache, scope).flow
val liveNsitesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultNsitesFollowList)
val liveNsitesFollowListsPerRelay = OutboxLoaderState(liveNsitesFollowLists, cache, scope).flow
val liveWorkoutsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultWorkoutsFollowList)
val liveWorkoutsFollowListsPerRelay = OutboxLoaderState(liveWorkoutsFollowLists, cache, scope).flow
val liveGitRepositoriesFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultGitRepositoriesFollowList)
val liveGitRepositoriesFollowListsPerRelay = OutboxLoaderState(liveGitRepositoriesFollowLists, cache, scope).flow
val liveCalendarsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultCalendarsFollowList)
val liveCalendarsFollowListsPerRelay = OutboxLoaderState(liveCalendarsFollowLists, cache, scope).flow
@@ -586,6 +602,11 @@ class Account(
val liveFollowPacksFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultFollowPacksFollowList)
val liveFollowPacksFollowListsPerRelay = OutboxLoaderState(liveFollowPacksFollowLists, cache, scope).flow
// App recommendations are read straight from LocalCache (no relay feed of its
// own), so only the in-memory author/tag matcher is needed here, not a
// per-relay outbox loader.
val liveAppRecommendationsFollowLists: StateFlow<IFeedTopNavFilter> = topNavFilterFlow(settings.defaultAppRecommendationsFollowList)
override fun isWriteable(): Boolean = settings.isWriteable()
suspend fun updateWarnReports(warnReports: Boolean): Boolean {
@@ -2032,6 +2053,14 @@ class Account(
extraNotesToBroadcast.forEach { client.publish(it, relays) }
}
/**
* The live [AddressableNote] backing a draft tag for this account. It is the same cached
* note that draft events are consumed into, so its `event` tracks the draft over time. The
* composer holds onto it (via DraftTagState) so [LocalCache]'s weak reference can't collect
* it before a deletion needs it, which would otherwise orphan the draft on the relays.
*/
fun getOrCreateDraftNote(draftTag: String): AddressableNote = cache.getOrCreateAddressableNote(DraftWrapEvent.createAddress(signer.pubKey, draftTag))
suspend fun createAndSendDraftIgnoreErrors(
draftTag: String,
template: EventTemplate<out Event>,
@@ -2068,18 +2097,25 @@ class Account(
}
}
suspend fun deleteDraftIgnoreErrors(draftTag: String) {
suspend fun deleteDraftIgnoreErrors(draftNote: AddressableNote?) {
try {
deleteDraftInner(draftTag)
deleteDraftInner(draftNote)
} catch (e: Exception) {
if (e is CancellationException) throw e
}
}
suspend fun deleteDraftInner(draftTag: String) {
suspend fun deleteDraftInner(draftNote: AddressableNote?) {
if (!isWriteable()) return
val extraRelays = cache.getAddressableNoteIfExists(DraftWrapEvent.createAddressTag(signer.pubKey, draftTag))?.relays ?: emptyList()
// Only a real, still-present draft needs a deletion signed. The note's event is null when
// no draft was ever saved (e.g. auto-drafts disabled) and already empty once it has been
// deleted — in both cases there is nothing to delete, so we avoid prompting the signer.
val draftEvent = draftNote?.event as? DraftWrapEvent
if (draftEvent == null || draftEvent.isDeleted()) return
val draftTag = draftNote.dTag()
val extraRelays = draftNote.relays
val deletedDraft = DraftWrapEvent.createDeletedEvent(draftTag, signer)
val deletionEvent = signer.sign(DeletionEvent.build(listOf(deletedDraft)))
@@ -2979,6 +3015,16 @@ class Account(
delete(note)
}
suspend fun addGitRepositoryBookmark(note: AddressableNote) {
if (!isWriteable()) return
sendMyPublicAndPrivateOutbox(gitRepositoryListState.addRepository(note))
}
suspend fun removeGitRepositoryBookmark(note: AddressableNote) {
if (!isWriteable()) return
gitRepositoryListState.removeRepository(note)?.let { sendMyPublicAndPrivateOutbox(it) }
}
suspend fun addBookmark(
note: Note,
isPrivate: Boolean,
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListR
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
@@ -189,7 +190,10 @@ class AccountSettings(
val defaultDiscoveryFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPollsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultPicturesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNappletsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultNsitesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultWorkoutsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultGitRepositoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCalendarsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultProductsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AroundMe),
val defaultShortsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
@@ -207,6 +211,7 @@ class AccountSettings(
val defaultBrowseEmojiSetsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultCommunitiesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
val defaultFollowPacksFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val defaultAppRecommendationsFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
val nwcWallets: MutableStateFlow<List<NwcWalletEntryNorm>> = MutableStateFlow(emptyList()),
val clinkDebitWallets: MutableStateFlow<List<ClinkDebitWalletEntryNorm>> = MutableStateFlow(emptyList()),
// The unified default spend rail (an NWC wallet OR a CLINK debit). Persisted under a
@@ -217,6 +222,7 @@ class AccountSettings(
var hideNIP17WarningDialog: Boolean = false,
val alwaysOnNotificationService: MutableStateFlow<Boolean> = MutableStateFlow(false),
val splitNotificationsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
val showMessagesInNotifications: MutableStateFlow<Boolean> = MutableStateFlow(true),
var backupUserMetadata: MetadataEvent? = null,
var backupContactList: ContactListEvent? = null,
var backupDMRelayList: ChatMessageRelayListEvent? = null,
@@ -262,6 +268,7 @@ class AccountSettings(
var callVideoResolution: CallVideoResolution = CallVideoResolution.HD_720,
var callMaxBitrateBps: Int = 1_500_000,
val callsEnabled: MutableStateFlow<Boolean> = MutableStateFlow(true),
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.IF_IN_MY_LIST),
) : EphemeralChatRepository,
PublicChatListRepository {
val saveable = MutableStateFlow(AccountSettingsUpdater(null))
@@ -295,6 +302,13 @@ class AccountSettings(
return newValue
}
fun toggleShowMessagesInNotifications(): Boolean {
val newValue = !showMessagesInNotifications.value
showMessagesInNotifications.tryEmit(newValue)
saveAccountSettings()
return newValue
}
// ---
// Zaps and Reactions
// ---
@@ -631,6 +645,28 @@ class AccountSettings(
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
fun changeDefaultNappletsFollowList(name: TopFilter) {
if (defaultNappletsFollowList.value != name) {
defaultNappletsFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultNsitesFollowList(name: FeedDefinition) {
changeDefaultNsitesFollowList(name.code)
}
fun changeDefaultNsitesFollowList(name: TopFilter) {
if (defaultNsitesFollowList.value != name) {
defaultNsitesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
@@ -642,6 +678,17 @@ class AccountSettings(
}
}
fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) {
changeDefaultGitRepositoriesFollowList(name.code)
}
fun changeDefaultGitRepositoriesFollowList(name: TopFilter) {
if (defaultGitRepositoriesFollowList.value != name) {
defaultGitRepositoriesFollowList.tryEmit(name)
saveAccountSettings()
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
@@ -818,6 +865,17 @@ class AccountSettings(
}
}
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
changeDefaultAppRecommendationsFollowList(name.code)
}
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
if (defaultAppRecommendationsFollowList.value != name) {
defaultAppRecommendationsFollowList.tryEmit(name)
saveAccountSettings()
}
}
// ---
// language services
// ---
@@ -1420,6 +1478,13 @@ class AccountSettings(
saveAccountSettings()
}
}
fun changeDefaultRelayAuthPolicy(policy: RelayAuthPolicy) {
if (defaultRelayAuthPolicy.value != policy) {
defaultRelayAuthPolicy.tryEmit(policy)
saveAccountSettings()
}
}
}
@Serializable
@@ -0,0 +1,71 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestUpdateEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Cross-screen index of the most recent NIP-34 pull-request update event
* (kind 1619) per parent pull-request id, kept up to date from
* [LocalCache.observeEvents]. A PR update *revises* its parent PR with a
* newer commit / merge base, so the UI folds the latest one into the PR rather
* than listing updates separately. Like [GitStatusIndex], updates aren't tracked
* in `Note.replies`, so a per-row cache scan would otherwise be required.
*
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
* 1619 (and seeds it from the cache index via `init()`), so [latestByPullRequest]
* is just that list reduced to the latest-per-parent map. Shared [SharingStarted.Eagerly]
* — never `WhileSubscribed` — because callers read `.value` synchronously and must
* not see a stale map when no one is actively collecting. `null` means "not loaded yet".
*/
object GitPullRequestUpdateIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val latestByPullRequest: StateFlow<Map<HexKey, GitPullRequestUpdateEvent>?> =
LocalCache
.observeEvents<GitPullRequestUpdateEvent>(Filter(kinds = listOf(GitPullRequestUpdateEvent.KIND)))
.map { latestByParent(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun latestByParent(events: List<GitPullRequestUpdateEvent>): Map<HexKey, GitPullRequestUpdateEvent> {
val latest = HashMap<HexKey, GitPullRequestUpdateEvent>()
for (event in events) {
val target = event.parentPullRequestId() ?: continue
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
latest[target] = event
}
}
return latest
}
}
@@ -20,66 +20,78 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.LocalCache.observeEvents
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
/**
* Cross-screen index of the most recent NIP-34 status event (kinds
* 1630-1633) per target id, kept up to date from
* [LocalCache.live.newEventBundles]. Status events are not tracked in
* [LocalCache.observeEvents]. Status events are not tracked in
* `Note.replies` (see `LocalCache.computeReplyTo`), so the only way to
* find them otherwise would be a full cache scan per row.
*
* The kind-indexed [observeEvents] re-emits the whole matching list on every new
* status event (and seeds it from the cache index via `init()`), so [latestByTarget]
* is just that list reduced to the latest-per-target map. Shared [SharingStarted.Eagerly]
* — never `WhileSubscribed` — because callers (e.g. [isClosedOrResolved] and the feed
* filters) read `.value` synchronously and must not see a stale map when no one is
* actively collecting. `null` means "not loaded yet".
*/
object GitStatusIndex {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val started = AtomicBoolean(false)
private val mutableLatestByTarget = MutableStateFlow<Map<HexKey, GitStatusEvent>?>(null)
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> = mutableLatestByTarget.asStateFlow()
private val statusKinds =
listOf(
GitStatusEvent.KIND_OPEN,
GitStatusEvent.KIND_APPLIED,
GitStatusEvent.KIND_CLOSED,
GitStatusEvent.KIND_DRAFT,
)
fun startIfNeeded() {
if (!started.compareAndSet(false, true)) return
scope.launch {
// Subscribe to bundle updates BEFORE the initial scan via onStart, so any
// events that arrive between scan start and collector attach are picked up.
LocalCache.live.newEventBundles
.onStart {
val initial = HashMap<HexKey, GitStatusEvent>()
LocalCache.notes.forEach { _, note ->
val event = note.event as? GitStatusEvent ?: return@forEach
val target = event.rootEventId() ?: return@forEach
val current = initial[target]
if (current == null || event.createdAt > current.createdAt) {
initial[target] = event
}
}
mutableLatestByTarget.value = initial
}.collect { bundle -> processBundle(bundle) }
}
}
val latestByTarget: StateFlow<Map<HexKey, GitStatusEvent>?> =
LocalCache
.observeEvents<GitStatusEvent>(Filter(kinds = statusKinds))
.map { reduceLatestByTarget(it) }
.flowOn(Dispatchers.IO)
.stateIn(scope, SharingStarted.Eagerly, null)
private fun processBundle(bundle: Set<Note>) {
val snapshot = mutableLatestByTarget.value ?: emptyMap()
var modified: HashMap<HexKey, GitStatusEvent>? = null
for (note in bundle) {
val event = note.event as? GitStatusEvent ?: continue
private fun reduceLatestByTarget(events: List<GitStatusEvent>): Map<HexKey, GitStatusEvent> {
val latest = HashMap<HexKey, GitStatusEvent>()
for (event in events) {
val target = event.rootEventId() ?: continue
val map = modified ?: snapshot
val current = map[target]
val current = latest[target]
if (current == null || event.createdAt > current.createdAt) {
if (modified == null) modified = HashMap(snapshot)
modified[target] = event
latest[target] = event
}
}
modified?.let { mutableLatestByTarget.value = it }
return latest
}
/**
* Whether the latest status for [targetId] marks it as closed (kind 1632)
* or applied/resolved/merged (kind 1631). Items with no status event, or
* whose latest status is open (1630) or draft (1633), are considered open.
*
* Reads from the synchronous snapshot in [latestByTarget]; pass an explicit
* [map] to avoid re-reading the value across a batch.
*/
fun isClosedOrResolved(
targetId: HexKey,
map: Map<HexKey, GitStatusEvent>? = latestByTarget.value,
): Boolean {
val event = map?.get(targetId) ?: return false
return event is GitStatusClosedEvent || event is GitStatusAppliedEvent
}
}
@@ -54,11 +54,13 @@ import com.vitorpamplona.quartz.experimental.attestations.recommendation.Attesto
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdDetectionEvent
import com.vitorpamplona.quartz.experimental.birdstar.BirdexEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.RoomId
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.ExerciseTemplateEvent
import com.vitorpamplona.quartz.experimental.fitness.workout.WorkoutRecordEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent
@@ -75,6 +77,7 @@ import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent
import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.notifications.wake.WakeUpEvent
import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.roadstr.confirmation.RoadEventConfirmationEvent
import com.vitorpamplona.quartz.experimental.roadstr.report.RoadEventReportEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
@@ -220,6 +223,8 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -286,6 +291,8 @@ import com.vitorpamplona.quartz.nipF4Podcasts.authored.AuthoredPodcastsEvent
import com.vitorpamplona.quartz.nipF4Podcasts.episode.PodcastEpisodeEvent
import com.vitorpamplona.quartz.nipF4Podcasts.favorites.FavoritePodcastsListEvent
import com.vitorpamplona.quartz.nipF4Podcasts.metadata.PodcastMetadataEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.episode.Podcasting20EpisodeEvent
import com.vitorpamplona.quartz.nipXXPodcasting20.trailer.Podcasting20TrailerEvent
import com.vitorpamplona.quartz.utils.DualCase
import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.Log
@@ -3484,6 +3491,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is BirdDetectionEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
is Ps1SaveEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is CommentEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -3624,6 +3639,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is RootNappletEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is NamedNappletEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is ChessGameEvent -> {
consumeRegularEvent(event, relay, wasVerified)
}
@@ -3741,7 +3764,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
is PodcastMetadataEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
// Drop the known "Mock Podcast" spam flood instead of caching thousands of them.
if (event.isMockSpam()) {
false
} else {
consumeBaseReplaceable(event, relay, wasVerified)
}
}
is AuthoredPodcastsEvent -> {
@@ -3752,6 +3780,14 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20EpisodeEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is Podcasting20TrailerEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is LnZapEvent -> {
consume(event, relay, wasVerified)
}
@@ -4004,6 +4040,10 @@ object LocalCache : ILocalCache, ICacheProvider {
consumeRegularEvent(event, relay, wasVerified)
}
is ExerciseTemplateEvent -> {
consumeBaseReplaceable(event, relay, wasVerified)
}
is PaymentTargetsEvent -> {
consume(event, relay, wasVerified)
}
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.serialization.Serializable
@Stable
@@ -44,7 +44,7 @@ data class UiSettings(
val automaticallyProposeAiImprovements: BooleanType = BooleanType.ALWAYS,
val useTrackedBroadcasts: BooleanType = BooleanType.ALWAYS,
val automaticallyCreateDrafts: BooleanType = BooleanType.ALWAYS,
val bottomBarItems: List<NavBarItem> = DefaultBottomBarItems,
val bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
val showHomeNewThreadsTab: Boolean = true,
val showHomeConversationsTab: Boolean = true,
val showHomeEverythingTab: Boolean = false,
@@ -54,6 +54,15 @@ data class UiSettings(
val showProfileFollowersFeed: Boolean = true,
val dontShowOnchainPublicWarning: Boolean = false,
val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS,
val accentColor: AccentColorType = AccentColorType.PURPLE,
val fontFamily: FontFamilyType = FontFamilyType.SYSTEM,
val fontSize: FontSizeType = FontSizeType.NORMAL,
val composeSignature: String = "",
// Whether the on-chain (Bitcoin/Taproot) wallet is shown across the app: the
// "Bitcoin" card on the wallet screen, the on-chain chip on profiles, and the
// on-chain rail in the Send Payment screen. Defaults to true (shown) so the
// behavior is unchanged for everyone who doesn't turn it off.
val showOnchainWallet: Boolean = true,
)
enum class ThemeType(
@@ -73,6 +82,68 @@ fun parseThemeType(code: Int?): ThemeType =
else -> ThemeType.SYSTEM
}
enum class AccentColorType(
val screenCode: Int,
val resourceId: Int,
) {
PURPLE(0, R.string.accent_color_purple),
BLUE(1, R.string.accent_color_blue),
GREEN(2, R.string.accent_color_green),
ORANGE(3, R.string.accent_color_orange),
RED(4, R.string.accent_color_red),
PINK(5, R.string.accent_color_pink),
}
fun parseAccentColorType(screenCode: Int): AccentColorType =
when (screenCode) {
AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE
AccentColorType.BLUE.screenCode -> AccentColorType.BLUE
AccentColorType.GREEN.screenCode -> AccentColorType.GREEN
AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE
AccentColorType.RED.screenCode -> AccentColorType.RED
AccentColorType.PINK.screenCode -> AccentColorType.PINK
else -> AccentColorType.PURPLE
}
enum class FontFamilyType(
val screenCode: Int,
val resourceId: Int,
) {
SYSTEM(0, R.string.font_family_system),
SANS_SERIF(1, R.string.font_family_sans_serif),
SERIF(2, R.string.font_family_serif),
MONOSPACE(3, R.string.font_family_monospace),
}
fun parseFontFamilyType(screenCode: Int): FontFamilyType =
when (screenCode) {
FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM
FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF
FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF
FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE
else -> FontFamilyType.SYSTEM
}
enum class FontSizeType(
val scale: Float,
val screenCode: Int,
val resourceId: Int,
) {
SMALL(0.85f, 0, R.string.font_size_small),
NORMAL(1.0f, 1, R.string.font_size_normal),
LARGE(1.15f, 2, R.string.font_size_large),
HUGE(1.3f, 3, R.string.font_size_huge),
}
fun parseFontSizeType(screenCode: Int): FontSizeType =
when (screenCode) {
FontSizeType.SMALL.screenCode -> FontSizeType.SMALL
FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL
FontSizeType.LARGE.screenCode -> FontSizeType.LARGE
FontSizeType.HUGE.screenCode -> FontSizeType.HUGE
else -> FontSizeType.NORMAL
}
enum class ConnectivityType(
val prefCode: Boolean?,
val screenCode: Int,
@@ -21,8 +21,8 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
@@ -44,7 +44,7 @@ class UiSettingsFlow(
val automaticallyProposeAiImprovements: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val useTrackedBroadcasts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val automaticallyCreateDrafts: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val bottomBarItems: MutableStateFlow<List<NavBarItem>> = MutableStateFlow(DefaultBottomBarItems),
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>> = MutableStateFlow(DefaultBottomBarEntries),
val showHomeNewThreadsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeConversationsTab: MutableStateFlow<Boolean> = MutableStateFlow(true),
val showHomeEverythingTab: MutableStateFlow<Boolean> = MutableStateFlow(false),
@@ -54,6 +54,11 @@ class UiSettingsFlow(
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE),
val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM),
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
val composeSignature: MutableStateFlow<String> = MutableStateFlow(""),
val showOnchainWallet: MutableStateFlow<Boolean> = MutableStateFlow(true),
) {
val listOfFlows: List<Flow<Any?>> =
listOf<Flow<Any?>>(
@@ -82,6 +87,11 @@ class UiSettingsFlow(
showProfileFollowersFeed,
dontShowOnchainPublicWarning,
suggestWorkoutsFromHealthConnect,
accentColor,
fontFamily,
fontSize,
composeSignature,
showOnchainWallet,
)
// emits at every change in any of the propertyes.
@@ -104,7 +114,7 @@ class UiSettingsFlow(
flows[12] as BooleanType,
flows[13] as BooleanType,
flows[14] as BooleanType,
flows[15] as List<NavBarItem>,
flows[15] as List<BottomBarEntry>,
flows[16] as Boolean,
flows[17] as Boolean,
flows[18] as Boolean,
@@ -114,6 +124,11 @@ class UiSettingsFlow(
flows[22] as Boolean,
flows[23] as Boolean,
flows[24] as BooleanType,
flows[25] as AccentColorType,
flows[26] as FontFamilyType,
flows[27] as FontSizeType,
flows[28] as String,
flows[29] as Boolean,
)
}
@@ -144,6 +159,11 @@ class UiSettingsFlow(
showProfileFollowersFeed.value,
dontShowOnchainPublicWarning.value,
suggestWorkoutsFromHealthConnect.value,
accentColor.value,
fontFamily.value,
fontSize.value,
composeSignature.value,
showOnchainWallet.value,
)
fun update(torSettings: UiSettings): Boolean {
@@ -249,6 +269,26 @@ class UiSettingsFlow(
suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect)
any = true
}
if (accentColor.value != torSettings.accentColor) {
accentColor.tryEmit(torSettings.accentColor)
any = true
}
if (fontFamily.value != torSettings.fontFamily) {
fontFamily.tryEmit(torSettings.fontFamily)
any = true
}
if (fontSize.value != torSettings.fontSize) {
fontSize.tryEmit(torSettings.fontSize)
any = true
}
if (composeSignature.value != torSettings.composeSignature) {
composeSignature.tryEmit(torSettings.composeSignature)
any = true
}
if (showOnchainWallet.value != torSettings.showOnchainWallet) {
showOnchainWallet.tryEmit(torSettings.showOnchainWallet)
any = true
}
return any
}
@@ -299,6 +339,11 @@ class UiSettingsFlow(
MutableStateFlow(uiSettings.showProfileFollowersFeed),
MutableStateFlow(uiSettings.dontShowOnchainPublicWarning),
MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect),
MutableStateFlow(uiSettings.accentColor),
MutableStateFlow(uiSettings.fontFamily),
MutableStateFlow(uiSettings.fontSize),
MutableStateFlow(uiSettings.composeSignature),
MutableStateFlow(uiSettings.showOnchainWallet),
)
}
}
@@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.model
import android.util.LruCache
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.preview.UrlPreview
import com.vitorpamplona.amethyst.ui.components.UrlPreviewState
import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState
import okhttp3.OkHttpClient
@Stable
@@ -108,6 +108,39 @@ class AccountCacheState(
}
}
/** The on-disk root that [loadAccount] creates a per-account directory under. */
private fun accountsRootDir() = File(rootFilesDir(), "accounts")
/**
* Deletes the on-disk per-account directory (the MLS/Marmot stores created in
* [loadAccount]). Call only on permanent account deletion — [removeAccount] just
* drops the in-memory copy and leaves these files behind.
*/
fun deleteAccountFiles(pubkey: HexKey) {
val dir = File(accountsRootDir(), pubkey)
if (dir.exists() && !dir.deleteRecursively()) {
Log.w("AccountCacheState", "Failed to delete account directory ${dir.absolutePath}")
}
}
/**
* Removes per-account directories left behind by accounts that are no longer saved
* (e.g. deleted before [deleteAccountFiles] existed). Keeps only [keepPubkeys]. Safe to
* run alongside [loadAccount]: it only loads saved accounts, whose pubkeys are kept.
*/
fun pruneOrphanAccountDirs(keepPubkeys: Set<HexKey>) {
val children = accountsRootDir().listFiles() ?: return
children.forEach { child ->
if (child.isDirectory && child.name !in keepPubkeys) {
if (child.deleteRecursively()) {
Log.d("AccountCacheState") { "Pruned orphan account dir ${child.name.take(8)}" }
} else {
Log.w("AccountCacheState", "Failed to prune orphan account dir ${child.absolutePath}")
}
}
}
}
fun loadAccount(accountSettings: AccountSettings): Account =
loadAccount(
signer =
@@ -0,0 +1,65 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.nip01UserMetadata
import com.vitorpamplona.amethyst.model.edits.PrivateStorageRelayListState
import com.vitorpamplona.amethyst.model.localRelays.LocalRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.stateIn
/**
* The set of relays to read the user's *own* events from for the "Mine" top-nav selection:
* the user's NIP-65 outbox, their private-storage relays, their local relays and their proxy
* relays. Deliberately mirrors [AccountOutboxRelayState] **minus broadcast**: broadcast relays are
* write-only blast targets, so reading the user's own content back from them is wrong — they don't
* serve reads and would only waste a subscription.
*/
class AccountMineRelayState(
nip65: Nip65RelayListState,
privateStorage: PrivateStorageRelayListState,
local: LocalRelayListState,
proxy: ProxyRelayListState,
scope: CoroutineScope,
) {
val flow =
combine(
nip65.outboxFlow,
privateStorage.flow,
local.flow,
proxy.flow,
) { nip65Outbox, privateOutBox, localRelays, proxyRelays ->
nip65Outbox + privateOutBox + localRelays + proxyRelays
}.flowOn(Dispatchers.IO)
.stateIn(
scope,
SharingStarted.Eagerly,
nip65.outboxFlow.value +
privateStorage.flow.value +
local.flow.value +
proxy.flow.value,
)
}
@@ -36,6 +36,12 @@ class Nip11CachedRetriever(
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
private val retriever = Nip11Retriever(okHttpClient)
fun trimToSize(maxItems: Int) {
relayInformationDocumentCache.trimToSize(maxItems)
// relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders;
// trimming it saves negligible memory but forces redundant NIP-11 HTTP fetches on resume.
}
fun getEmpty(relay: NormalizedRelayUrl): Nip11RelayInformation {
relayInformationEmptyCache.get(relay)?.let { return it }
@@ -23,3 +23,5 @@ package com.vitorpamplona.amethyst.model.nip51Lists
typealias BookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
typealias OldBookmarkListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
typealias GitRepositoryListState = com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
@@ -68,7 +68,8 @@ class TrustedRelayListState(
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
// Synchronously seed public tags from the backup; private tags may be absent on first boot.
settings.backupTrustedRelayList?.let { decryptionCache.cachedRelays(it) } ?: emptySet(),
)
suspend fun saveRelayList(trustedRelays: List<NormalizedRelayUrl>): TrustedRelayListEvent {
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.amethyst.model.nip60Cashu
import com.vitorpamplona.amethyst.commons.cashu.CashuWalletReader
import com.vitorpamplona.amethyst.commons.cashu.ops.CashuWalletOps
import com.vitorpamplona.amethyst.commons.cashu.ops.MeltCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.NutzapSent
import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -35,7 +43,6 @@ import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MeltQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.ProofState
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
@@ -45,7 +52,6 @@ import com.vitorpamplona.quartz.nip61Nutzaps.info.NutzapInfoEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nip87Ecash.recommendation.MintRecommendationEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.secp256k1.Secp256k1
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -97,6 +103,8 @@ class CashuWalletState(
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val settings: AccountSettings,
okHttpClient: (String) -> OkHttpClient,
) {
@@ -436,12 +444,31 @@ class CashuWalletState(
triggerAutoRedeem()
}
// Keep the relay subscription in sync with the outbox set.
// Keep the wallet subscription in sync with the relay sets it reads
// from. Following the NIP-65 outbox model, the two halves of the
// subscription read from different places:
// - our own NIP-60 events (wallet/token/history) are read back from
// our OUTBOX relays, where we published them;
// - inbound kind:9321 nutzaps are read from our INBOX set, since
// that is where other people deliver them. Per NIP-61 the source
// of truth for "where to send me nutzaps" is the `relay` tags in
// our own kind:10019 — and another client may have published that
// with relays unrelated to our NIP-65 lists — so we listen on the
// union of those plus our NIP-65 inbox + DM relays.
jobs +=
scope.launch(Dispatchers.IO) {
outboxRelaysFlow.collect { relays ->
syncSubscription(relays)
}
combine(
outboxRelaysFlow,
inboxRelaysFlow,
dmRelaysFlow,
_nutzapInfoEvent,
) { outbox, inbox, dm, info ->
CashuWalletQueryState(
pubkey = pubKey,
ownEventRelays = outbox,
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
)
}.collect { syncSubscription(it) }
}
// Reactive incremental update: any new event arrival that matches our
@@ -504,17 +531,16 @@ class CashuWalletState(
// ============================================================
// Subscription management
// ============================================================
private fun syncSubscription(relays: Set<NormalizedRelayUrl>) {
private fun syncSubscription(next: CashuWalletQueryState) {
val previous = currentSubscription
if (relays.isEmpty()) {
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous != null && previous.relays == relays) return // unchanged
if (previous == next) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
val next = CashuWalletQueryState(pubKey, relays)
currentSubscription = next
assembler.subscribe(next)
}
@@ -712,44 +738,13 @@ class CashuWalletState(
}
}
// Apply `del` rollover.
val deletedIds = mutableSetOf<HexKey>()
all.forEach { evt -> tokenContents[evt.id]?.del?.let(deletedIds::addAll) }
val unspent =
all
.filter { it.id !in deletedIds && tokenContents.containsKey(it.id) }
.mapNotNull { evt -> tokenContents[evt.id]?.let { TokenEntry(evt, it) } }
.sortedByDescending { it.event.createdAt }
_tokenEntries.value = unspent
// Shared del-rollover + sort with the headless reader.
_tokenEntries.value = CashuWalletReader.computeUnspent(all, tokenContents)
}
private fun recomputePending() {
val now = TimeUtils.now()
// A quote is "pending" if (1) not expired, and (2) no kind:7376 history
// event references its id with a "destroyed" marker — completion of the
// mint flow deletes the kind:7374, and history records a `destroyed`
// reference to the now-fulfilled quote.
val destroyedQuoteIds =
historyEvents.values
.asSequence()
.flatMap { it.tags.asSequence() }
.filter { it.size >= 4 && it[0] == "e" && it[3] == "destroyed" }
.map { it[1] }
.toSet()
_pendingQuotes.value =
quoteEvents.values
.filter { it.id !in destroyedQuoteIds }
.filter { evt ->
val exp =
evt.tags
.firstOrNull { it.size >= 2 && it[0] == "expiration" }
?.get(1)
?.toLongOrNull()
exp == null || exp > now
}.sortedByDescending { it.createdAt }
// Shared destroyed/expired filter with the headless reader.
_pendingQuotes.value = CashuWalletReader.computePending(quoteEvents.values, historyEvents.values)
}
private fun scanCacheForOwnEvents(): List<Event> {
@@ -875,7 +870,11 @@ class CashuWalletState(
ops.publishWalletEvents(
mints = currentMints,
p2pkPrivkeyHex = manualPrivkeyHex?.takeIf { it.isNotBlank() },
nutzapRelays = outboxRelaysFlow.value.toList(),
// Advertise our NIP-65 inbox relays as the nutzap relays (NIP-65
// outbox model): senders publish kind:9321 where we read inbound
// events. We listen on a wider set (inbox + DM + these tags), but
// the kind:10019 default copies the inbox relay list, not outbox.
nutzapRelays = inboxRelaysFlow.value.toList(),
)
// The NUT-13 seed (derived from the P2PK key) is invalidated by
// applyEvents when the new kind:17375 round-trips in, so it re-derives
@@ -1040,6 +1039,161 @@ class CashuWalletState(
return outcome
}
// ============================================================
// Find-my-wallet wizard support (cross-relay discovery)
// ============================================================
/**
* Decrypt a discovered kind:17375 (possibly authored by another client,
* pulled in by [com.vitorpamplona.amethyst.ui.screen.loggedIn.wallet.wizard.CashuWalletDiscovery])
* into its mint list + wallet P2PK private key. Returns null if the
* content can't be decrypted (not really ours / signer rejected).
*/
suspend fun decryptDiscoveredWallet(event: CashuWalletEvent): DiscoveredWalletConfig? =
runCatching {
DiscoveredWalletConfig(
mints = event.mints(signer),
privkeyHex = event.privkey(signer),
)
}.onFailure {
Log.w("CashuWallet") { "Failed to decrypt discovered wallet ${event.id.take(8)}: ${it.message}" }
}.getOrNull()
/**
* Read-only probe of how much ecash is recoverable from a wallet's
* NUT-13 seed, per mint, WITHOUT publishing anything. Drives the
* wizard's "structural + balance" verification of each discovered
* wallet — including OLD/duplicate wallets whose [privkeyHex] differs
* from the main wallet's. Mints that error (unreachable, no funds) are
* simply absent from the result. Secrets already held by the main
* wallet are excluded so the figure reflects *new* recoverable funds.
*/
suspend fun probeRecoverableFromSeed(
privkeyHex: String,
mints: List<String>,
): Map<String, Long> {
check(started) { NOT_STARTED_MESSAGE }
val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray())
val existingSecrets =
_tokenEntries.value
.flatMap { it.content.proofs }
.mapTo(HashSet()) { it.secret }
val result = LinkedHashMap<String, Long>()
for (mint in mints) {
val recoverable =
runCatching { ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets) }
.onFailure { Log.w("CashuWallet") { "Probe of $mint failed: ${describeMintError(it)}" } }
.getOrNull() ?: continue
if (!recoverable.isEmpty) result[mint] = recoverable.amountSats
}
return result
}
/**
* Recover every spendable proof derivable from [privkeyHex]'s NUT-13
* seed at [mints] into the **current** wallet: each mint is re-scanned
* (NUT-09 + NUT-07) and its UNSPENT proofs are published as fresh
* kind:7375 owned by this account. Funds locked to an OLD wallet's key
* thus land in the main wallet as bearer proofs (surfacing as a
* configured- or unconfigured-mint balance the user can then move).
*
* [bumpCounter] advances this wallet's persistent NUT-13 counter past the
* recovered slots — correct ONLY when [privkeyHex] is the **main** wallet's
* own key (the wizard adopting a discovered wallet's own balance). For a
* FOREIGN old/duplicate wallet it must stay false: the foreign seed's slots
* are unrelated to the main seed's, so bumping would corrupt the main
* wallet's counter (see [CashuWalletOps.publishRecoveredProofs]).
*
* Returns the total sats recovered across all mints.
*/
suspend fun recoverFromSeed(
privkeyHex: String,
mints: List<String>,
bumpCounter: Boolean = false,
): Long {
check(started) { NOT_STARTED_MESSAGE }
val seed = CashuDeterministic.deriveWalletSeed(privkeyHex.hexToByteArray())
var total = 0L
for (mint in mints) {
// existingSecrets is re-read per mint so a proof published while
// recovering an earlier mint isn't double-counted here.
val existingSecrets =
_tokenEntries.value
.flatMap { it.content.proofs }
.mapTo(HashSet()) { it.secret }
val outcome =
runCatching {
val recoverable = ops.scanRecoverableProofs(mint, seed, existingSecrets = existingSecrets)
val published = ops.publishRecoveredProofs(recoverable)
if (bumpCounter) {
// Advance past EVERY slot the mint signed, even when all
// recovered proofs were already spent (recoverable.proofs
// empty after the NUT-07 filter). nextCounterAfterScan is
// set from the pre-checkstate scan, so the delta still
// covers those slots — without this a fully-spent adopt on
// a fresh device would leave the counter at 0 and the next
// mint would reuse an already-signed slot (unspendable).
val current = settings.peekCashuCounter(recoverable.keysetId)
val delta = (recoverable.nextCounterAfterScan - current).coerceAtLeast(0L)
if (delta > 0) settings.reserveCashuCounters(recoverable.keysetId, delta.toInt())
}
published
}.onFailure { Log.w("CashuWallet") { "Recover from $mint failed: ${describeMintError(it)}" } }
.getOrNull() ?: continue
total += outcome.amountRecoveredSats
}
return total
}
/**
* Adopt a discovered wallet as the account's live + main wallet by
* **re-signing a fresh** kind:17375 + kind:10019 with the discovered
* wallet's own mints and P2PK key.
*
* We deliberately do NOT rebroadcast the discovered event verbatim. The
* crawl may surface a wallet the user already DELETED: the user's own
* NIP-09 kind:5 (from [CashuWalletOps.deleteWallet]) carries both an `e`
* tag (the old event id) and an `a` tag (the replaceable `17375:pubkey:`
* address). Re-publishing the same event loses on both — relays that
* honored the deletion reject the duplicate id, and the `a`-tag rule
* re-deletes any version with `created_at <= deletion.created_at` the
* moment the kind:5 propagates back (on relays and in our own LocalCache).
*
* A freshly-signed event sidesteps both: a new id isn't covered by the
* `e` tag, and `created_at = now` is newer than the past deletion so it
* survives the `a`-tag rule. Same key + mints means the same nutzap
* address and the same recoverable funds (the NUT-13 seed is derived from
* the key, independent of the event id). [nutzapInfo] is unused in this
* path — publishWalletEvents re-issues a fresh kind:10019 advertising our
* current inbox relays — but is kept for the decrypt-failure fallback.
*/
suspend fun adoptDiscoveredWallet(
wallet: CashuWalletEvent,
nutzapInfo: NutzapInfoEvent? = null,
) {
check(started) { NOT_STARTED_MESSAGE }
val config = decryptDiscoveredWallet(wallet)
if (config != null && config.mints.isNotEmpty()) {
ops.publishWalletEvents(
mints = config.mints,
p2pkPrivkeyHex = config.privkeyHex,
nutzapRelays = inboxRelaysFlow.value.toList(),
)
} else {
// Couldn't decrypt (shouldn't happen for a wallet the wizard
// surfaced as valid) — fall back to rebroadcasting the raw event so
// it's at least findable. This keeps the original id/created_at and
// so remains vulnerable to a prior deletion, but it's the best we
// can do without the plaintext to re-sign from.
LocalCache.justConsumeMyOwnEvent(wallet)
publishEvent(wallet)
if (nutzapInfo != null) {
LocalCache.justConsumeMyOwnEvent(nutzapInfo)
publishEvent(nutzapInfo)
}
}
}
/**
* Scan held kind:7375 events for accidental duplicates and NIP-09
* delete the redundant ones. An event B is redundant when there
@@ -1142,38 +1296,21 @@ class CashuWalletState(
.groupBy { it.content.mint }
.filterKeys { mintUrlFilter == null || it == mintUrlFilter }
for ((mintUrl, entries) in byMint) {
val allProofs = entries.flatMap { it.content.proofs }
if (allProofs.isEmpty()) continue
val states =
runCatching { ops.checkProofStates(mintUrl, allProofs) }
// Shared NUT-07 check + NIP-09 delete with amy's `cashu maintenance
// scrub`. Returns the stale token events it published a deletion for.
val staleEvents =
runCatching { ops.scrubStaleProofs(mintUrl, entries) }
.onFailure {
Log.w("CashuWallet", "checkProofStates($mintUrl) failed; skipping sweep", it)
Log.w("CashuWallet", "scrubStaleProofs($mintUrl) failed; skipping sweep", it)
}.getOrNull()
?: continue
// Any entry with at least one SPENT proof gets purged. Keeping
// mixed-state entries around would let the next send pick them
// and trip the same HTTP 400 we're trying to prevent.
val staleEntries =
entries.filter { entry ->
entry.content.proofs.any { states[it.secret] == ProofState.SPENT }
}
if (staleEntries.isEmpty()) continue
if (staleEvents.isEmpty()) continue
Log.i("CashuWallet") {
"Scrubbing ${staleEntries.size} stale kind:7375 event(s) at $mintUrl"
"Scrubbing ${staleEvents.size} stale kind:7375 event(s) at $mintUrl"
}
val staleIds = staleEntries.map { it.event.id }.toSet()
runCatching {
val template = DeletionEvent.build(staleEntries.map { it.event })
val signed = signer.sign(template)
publishEvent(signed)
}.onFailure {
Log.w("CashuWallet", "Failed to NIP-09 delete stale entries for $mintUrl", it)
}
// Drop from internal indexes regardless of publish success — even
// if the kind:5 didn't go out, we know these proofs are unusable
// and shouldn't be selected for the next swap.
removeEvents(staleIds)
// Drop from internal indexes — even if the kind:5 didn't reach a
// relay, these proofs are unusable and must not be re-selected.
removeEvents(staleEvents.map { it.id }.toSet())
}
}
@@ -1453,6 +1590,15 @@ class CashuWalletState(
}
}
/**
* Decrypted config of a discovered kind:17375 — its configured [mints] and
* wallet P2PK private key. See [CashuWalletState.decryptDiscoveredWallet].
*/
data class DiscoveredWalletConfig(
val mints: List<String>,
val privkeyHex: String?,
)
/** Mint + recipient pubkey resolved from a kind:10019. */
data class NutzapTarget(
val mintUrl: String,
@@ -31,15 +31,20 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.AccentColorType
import com.vitorpamplona.amethyst.model.BooleanType
import com.vitorpamplona.amethyst.model.ConnectivityType
import com.vitorpamplona.amethyst.model.FeatureSetType
import com.vitorpamplona.amethyst.model.FontFamilyType
import com.vitorpamplona.amethyst.model.FontSizeType
import com.vitorpamplona.amethyst.model.ProfileGalleryType
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.UiSettingsFlow
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -118,6 +123,11 @@ class UiSharedPreferences(
val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed")
val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning")
val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect")
val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color")
val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family")
val UI_FONT_SIZE = stringPreferencesKey("ui.font_size")
val UI_COMPOSE_SIGNATURE = stringPreferencesKey("ui.compose_signature")
val UI_SHOW_ONCHAIN_WALLET = booleanPreferencesKey("ui.show_onchain_wallet")
suspend fun uiPreferences(context: Context): UiSettings? =
try {
@@ -144,7 +154,7 @@ class UiSharedPreferences(
preferences[UI_USE_TRACKED_BROADCASTS]?.let { BooleanType.valueOf(it) }
?: if (featureSet == FeatureSetType.COMPLETE) BooleanType.ALWAYS else BooleanType.NEVER,
automaticallyCreateDrafts = preferences[UI_AUTOMATICALLY_CREATE_DRAFTS]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarItems,
bottomBarItems = preferences[UI_BOTTOM_BAR_ITEMS]?.let { decodeBottomBarItems(it) } ?: DefaultBottomBarEntries,
showHomeNewThreadsTab = preferences[UI_SHOW_HOME_NEW_THREADS_TAB] ?: true,
showHomeConversationsTab = preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] ?: true,
showHomeEverythingTab = preferences[UI_SHOW_HOME_EVERYTHING_TAB] ?: false,
@@ -155,6 +165,11 @@ class UiSharedPreferences(
dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false,
suggestWorkoutsFromHealthConnect =
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS,
accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE,
fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM,
fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL,
composeSignature = preferences[UI_COMPOSE_SIGNATURE] ?: "",
showOnchainWallet = preferences[UI_SHOW_ONCHAIN_WALLET] ?: true,
)
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -194,7 +209,7 @@ class UiSharedPreferences(
preferences[UI_PROPOSE_AI_IMPROVEMENTS] = sharedSettings.automaticallyProposeAiImprovements.name
preferences[UI_USE_TRACKED_BROADCASTS] = sharedSettings.useTrackedBroadcasts.name
preferences[UI_AUTOMATICALLY_CREATE_DRAFTS] = sharedSettings.automaticallyCreateDrafts.name
preferences[UI_BOTTOM_BAR_ITEMS] = sharedSettings.bottomBarItems.joinToString(",") { it.name }
preferences[UI_BOTTOM_BAR_ITEMS] = encodeBottomBarItems(sharedSettings.bottomBarItems)
preferences[UI_SHOW_HOME_NEW_THREADS_TAB] = sharedSettings.showHomeNewThreadsTab
preferences[UI_SHOW_HOME_CONVERSATIONS_TAB] = sharedSettings.showHomeConversationsTab
preferences[UI_SHOW_HOME_EVERYTHING_TAB] = sharedSettings.showHomeEverythingTab
@@ -204,6 +219,11 @@ class UiSharedPreferences(
preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed
preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning
preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name
preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name
preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name
preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name
preferences[UI_COMPOSE_SIGNATURE] = sharedSettings.composeSignature
preferences[UI_SHOW_ONCHAIN_WALLET] = sharedSettings.showOnchainWallet
}
} catch (e: Exception) {
if (e is CancellationException) throw e
@@ -212,17 +232,41 @@ class UiSharedPreferences(
}
}
private fun decodeBottomBarItems(raw: String): List<NavBarItem> {
if (raw.isEmpty()) return emptyList()
return raw
.split(",")
.mapNotNull { name ->
try {
NavBarItem.valueOf(name)
} catch (_: IllegalArgumentException) {
null
}
}
/**
* Persists "follow the defaults" as a blank sentinel instead of the concrete default list.
*
* A user who resets the bottom bar (or who never customized it) should track whatever
* [DefaultBottomBarEntries] is in the *installed* app version. Storing the concrete list would
* pin them to today's default, so a future version that changes the default would never reach
* them. Storing a blank value instead makes [decodeBottomBarItems] resolve it back to the
* current [DefaultBottomBarEntries] on every load — i.e. the user is automatically migrated to
* the new default. Any genuinely customized bar is still stored as JSON.
*/
internal fun encodeBottomBarItems(items: List<BottomBarEntry>): String = if (items == DefaultBottomBarEntries) "" else JsonMapper.toJson(items)
internal fun decodeBottomBarItems(raw: String): List<BottomBarEntry>? {
if (raw.isBlank()) return DefaultBottomBarEntries
// Current format: a JSON list of BottomBarEntry (built-ins + favorites).
runCatching { return JsonMapper.fromJson<List<BottomBarEntry>>(raw) }
// Configs written before the stable @SerialName discriminators used the fully-qualified
// class name as the polymorphic "type" value. Rewrite it to the short name and retry, so a
// customized bar survives the upgrade instead of silently resetting to defaults.
runCatching {
val migrated =
raw
.replace(LEGACY_BUILTIN_DISCRIMINATOR, "builtIn")
.replace(LEGACY_FAVORITE_DISCRIMINATOR, "favorite")
return JsonMapper.fromJson<List<BottomBarEntry>>(migrated)
}
// Oldest format: comma-joined NavBarItem enum names (before favorites/unified entries).
val legacy = raw.split(",").mapNotNull { name -> runCatching { NavBarItem.valueOf(name) }.getOrNull() }
if (legacy.isNotEmpty()) return legacy.map { BottomBarEntry.BuiltIn(it) }
// Unrecognizable — fall back to the defaults rather than leaving the bar empty.
return DefaultBottomBarEntries
}
// The pre-@SerialName polymorphic discriminators (fully-qualified class names) for migration.
private const val LEGACY_BUILTIN_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.BuiltIn"
private const val LEGACY_FAVORITE_DISCRIMINATOR = "com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry.Favorite"
}
}
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAl
import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.MultiHashtagFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.mine.MineFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow
import com.vitorpamplona.amethyst.model.topNavFeeds.relay.RelayFeedFlow
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -62,6 +63,7 @@ class FeedTopNavFilterState(
val followsRelays: StateFlow<Set<NormalizedRelayUrl>>,
val blockedRelays: StateFlow<Set<NormalizedRelayUrl>>,
val proxyRelays: StateFlow<Set<NormalizedRelayUrl>>,
val mineRelays: StateFlow<Set<NormalizedRelayUrl>>,
val relayFeeds: StateFlow<Set<NormalizedRelayUrl>>,
val caches: FeedDecryptionCaches,
val signer: NostrSigner,
@@ -93,7 +95,7 @@ class FeedTopNavFilterState(
}
TopFilter.Mine -> {
AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays)
MineFeedFlow(signer.pubKey, mineRelays)
}
is TopFilter.Community, is TopFilter.PeopleList, is TopFilter.MuteList -> {
@@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.mine
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByProxyTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
/**
* Resolves the "Mine" top-nav selection to an author filter scoped to the logged-in user's own
* pubkey, pinned to the user's own relays — their outbox, private-storage, local and proxy relays
* (see [com.vitorpamplona.amethyst.model.nip01UserMetadata.AccountMineRelayState]). Unlike the
* follow filters, "Mine" doesn't need per-author outbox resolution from cache: the only author is
* the user, and the user's relays are already known from their own account state — so we pin the
* fixed set directly via [AuthorsByProxyTopNavFilter] (it associates each given relay with the
* authors, which is exactly "query my relays for my events").
*
* This is the single source of truth for "Mine". Both the relay sub-assemblers (through each
* screen's `liveXFollowListsPerRelay`) and the local DAL filters (through `liveXFollowLists`)
* consume the produced [AuthorsByProxyTopNavFilter], so screens no longer need a dedicated
* `TopFilter.Mine` branch: the generic author path already narrows to the user. It also makes
* relay-set changes re-invalidate automatically — `liveXFollowListsPerRelay` re-emits whenever
* [mineRelays] changes, through the sub-assemblers' existing `followsPerRelayFlow` collector.
*/
class MineFeedFlow(
val myPubkey: HexKey,
val mineRelays: StateFlow<Set<NormalizedRelayUrl>>,
) : IFeedFlowsType {
fun convert(relays: Set<NormalizedRelayUrl>): IFeedTopNavFilter =
AuthorsByProxyTopNavFilter(
authors = setOf(myPubkey),
proxyRelays = relays,
)
override fun flow(): Flow<IFeedTopNavFilter> = mineRelays.map(::convert)
override fun startValue(): IFeedTopNavFilter = convert(mineRelays.value)
override suspend fun startValue(collector: FlowCollector<IFeedTopNavFilter>) {
collector.emit(startValue())
}
}
@@ -0,0 +1,111 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionStore
import kotlinx.coroutines.flow.first
private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "napplet_permissions")
/**
* Persists the standing napplet grants ([GrantState.ALLOW_ALWAYS] / [GrantState.DENY]) in a
* dedicated DataStore. Keyed by `"<coordinate>\u0000<capability>"` so a coordinate's grants can
* be read and cleared as a group. Session and one-shot grants are not persisted — the ledger
* keeps those in memory.
*/
class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
) : NappletPermissionStore {
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
val prefs = dataStore.data.first()
val prefix = "$coordinate$SEP"
val result = mutableMapOf<NappletCapability, GrantState>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(prefix)) continue
val capability = runCatching { NappletCapability.valueOf(name.substring(prefix.length)) }.getOrNull() ?: continue
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
if (grant == GrantState.ALLOW_ALWAYS || grant == GrantState.DENY) result[capability] = grant
}
return result
}
override suspend fun store(
coordinate: String,
capability: NappletCapability,
grant: GrantState,
) {
if (grant != GrantState.ALLOW_ALWAYS && grant != GrantState.DENY) return
dataStore.edit { it[keyOf(coordinate, capability)] = grant.name }
}
override suspend fun clear(coordinate: String) {
val prefix = "$coordinate$SEP"
dataStore.edit { prefs ->
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
toRemove.forEach { prefs.remove(it) }
}
}
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
val prefs = dataStore.data.first()
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
for ((key, value) in prefs.asMap()) {
val name = key.name
// Key is "<coordinate> <CAPABILITY>"; the capability is the final space-delimited token.
val capName = name.substringAfterLast(SEP, "")
val coordinate = name.substringBeforeLast(SEP, "")
if (capName.isEmpty() || coordinate.isEmpty()) continue
val capability = runCatching { NappletCapability.valueOf(capName) }.getOrNull() ?: continue
val grant = runCatching { GrantState.valueOf(value as String) }.getOrNull() ?: continue
if (grant == GrantState.ALLOW_ALWAYS || grant == GrantState.DENY) {
result.getOrPut(coordinate) { mutableMapOf() }[capability] = grant
}
}
return result
}
override suspend fun remove(
coordinate: String,
capability: NappletCapability,
) {
dataStore.edit { it.remove(keyOf(coordinate, capability)) }
}
private fun keyOf(
coordinate: String,
capability: NappletCapability,
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
companion object {
private const val SEP = "\u0000"
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.vitorpamplona.amethyst.commons.napplet.NappletStorage
import kotlinx.coroutines.flow.first
private val Context.nappletStorageDataStore by preferencesDataStore(name = "napplet_storage")
/**
* DataStore-backed [NappletStorage]. Every key is prefixed with the applet's coordinate, so one
* napplet's keys can never collide with another's, and this store is entirely separate from the
* app's own preferences.
*/
class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
) : NappletStorage {
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
override suspend fun get(
coordinate: String,
key: String,
): String? = dataStore.data.first()[keyOf(coordinate, key)]
override suspend fun set(
coordinate: String,
key: String,
value: String,
) {
dataStore.edit { it[keyOf(coordinate, key)] = value }
}
override suspend fun remove(
coordinate: String,
key: String,
) {
dataStore.edit { it.remove(keyOf(coordinate, key)) }
}
override suspend fun keys(coordinate: String): List<String> {
val prefix = "$coordinate "
return dataStore.data
.first()
.asMap()
.keys
.map { it.name }
.filter { it.startsWith(prefix) }
.map { it.removePrefix(prefix) }
}
private fun keyOf(
coordinate: String,
key: String,
) = stringPreferencesKey("$coordinate\u0000$key")
}
@@ -0,0 +1,191 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
/**
* Per-coordinate DataStore-backed [NostrSignerPermissionStore]. One small `.preferences_pb`
* file per app (keyed by a SHA-256 prefix of the coordinate) so loading or saving one app's
* permissions never touches another app's data — essential at scale with 1000s of apps.
*
* The coordinate is stored inside each file under [KEY_COORDINATE] so [allPolicies] can
* reverse-map file → coordinate without scanning the filesystem.
*/
class DataStoreNostrSignerPermissionStore(
private val filesDir: File,
) : NostrSignerPermissionStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val cache = LargeCache<String, DataStore<Preferences>>()
private fun storeFor(coordinate: String): DataStore<Preferences> {
val file = File(filesDir, "datastore/nsp_${hash(coordinate)}.preferences_pb")
return cache.getOrCreate(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
}
override suspend fun loadPolicy(coordinate: String): AppSignerPolicy? {
val raw = storeFor(coordinate).data.first()[KEY_POLICY] ?: return null
return runCatching { AppSignerPolicy.valueOf(raw) }.getOrNull()
}
override suspend fun storePolicy(
coordinate: String,
policy: AppSignerPolicy,
) {
storeFor(coordinate).edit {
it[KEY_COORDINATE] = coordinate
it[KEY_POLICY] = policy.name
}
}
override suspend fun clearPolicy(coordinate: String) {
storeFor(coordinate).edit { it.remove(KEY_POLICY) }
}
override suspend fun loadOpDecision(
coordinate: String,
op: NostrSignerOp,
): NostrOpDecision? {
val raw = storeFor(coordinate).data.first()[opKey(op)] ?: return null
return runCatching { NostrOpDecision.valueOf(raw) }.getOrNull()
}
override suspend fun storeOpDecision(
coordinate: String,
op: NostrSignerOp,
decision: NostrOpDecision,
) {
storeFor(coordinate).edit {
it[KEY_COORDINATE] = coordinate
it[opKey(op)] = decision.name
}
}
override suspend fun clearOpDecision(
coordinate: String,
op: NostrSignerOp,
) {
storeFor(coordinate).edit { it.remove(opKey(op)) }
}
override suspend fun allPolicies(): Map<String, AppSignerPolicy> {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return emptyMap()
val result = mutableMapOf<String, AppSignerPolicy>()
for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) {
val ds =
cache.getOrCreate(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue
val policy = loadPolicy(coordinate) ?: continue
result[coordinate] = policy
}
return result
}
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()
val result = mutableMapOf<String, NostrOpDecision>()
for ((key, value) in prefs.asMap()) {
val name = key.name
if (!name.startsWith(OP_PREFIX)) continue
// Skip expiry metadata keys — they end with the expiry suffix
if (name.endsWith(OP_EXPIRY_SUFFIX)) continue
val opKey = name.removePrefix(OP_PREFIX)
val decision = runCatching { NostrOpDecision.valueOf(value as String) }.getOrNull() ?: continue
result[opKey] = decision
}
return result
}
override suspend fun loadOpExpiry(
coordinate: String,
op: NostrSignerOp,
): Long? {
val raw = storeFor(coordinate).data.first()[opExpiryKey(op)] ?: return null
return raw.toLongOrNull()
}
override suspend fun storeOpExpiry(
coordinate: String,
op: NostrSignerOp,
expiresAt: Long,
) {
storeFor(coordinate).edit { it[opExpiryKey(op)] = expiresAt.toString() }
}
override suspend fun clearOpExpiry(
coordinate: String,
op: NostrSignerOp,
) {
storeFor(coordinate).edit { it.remove(opExpiryKey(op)) }
}
override suspend fun loadLastUsed(coordinate: String): Long? {
val raw = storeFor(coordinate).data.first()[KEY_LAST_USED] ?: return null
return raw.toLongOrNull()
}
override suspend fun storeLastUsed(
coordinate: String,
epochSeconds: Long,
) {
storeFor(coordinate).edit { it[KEY_LAST_USED] = epochSeconds.toString() }
}
override suspend fun clearAll(coordinate: String) {
storeFor(coordinate).edit { it.clear() }
}
private fun opKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}")
private fun opExpiryKey(op: NostrSignerOp) = stringPreferencesKey("$OP_PREFIX${op.key}$OP_EXPIRY_SUFFIX")
companion object {
private val KEY_COORDINATE = stringPreferencesKey("coordinate")
private val KEY_POLICY = stringPreferencesKey("policy")
private val KEY_LAST_USED = stringPreferencesKey("lastused")
private const val OP_PREFIX = "op:"
private const val OP_EXPIRY_SUFFIX = ":exp"
private fun hash(coordinate: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
return digest.take(8).joinToString("") { "%02x".format(it) }
}
}
}
@@ -0,0 +1,431 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.app.Service
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.Message
import android.os.Messenger
import android.os.RemoteException
import android.os.SystemClock
import android.util.Log
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.gateways.AccountNappletGateways
import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.AccountState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
* service and sends requests as JSON over a [Messenger]; this is the only side that holds the
* signer, the relays, and the permission ledger. It runs each request through the shared
* [NappletBroker] (built per account by [AccountNappletGateways]) via the host-agnostic
* [NappletRequestRouter], which gates everything on consent and never returns key material.
*
* This class is intentionally thin: it owns the Messenger transport, the per-account broker cache,
* and the live-subscription registry, and delegates all decode/policy/execution to the shared core.
*
* `exported=false` in the manifest restricts binding to this app's own UID, so no other
* installed app can reach the broker. A renderer escape that reaches the `:napplet` process
* could still bind here — but it gains only what the user explicitly consents to, per
* capability, and never the private key (the broker's contract).
*/
class NappletBrokerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
// One ledger for the whole service lifetime: persistent grants on disk, session grants in RAM.
private val ledger by lazy { NappletPermissionLedger(Amethyst.instance.nappletPermissionStore) }
// Per-app internal-signer permission ledger (policy + per-op overrides). Lazy so it's only
// instantiated in the main process where the signer lives; never touched from :napplet.
private val signerLedger by lazy { NostrSignerPermissionLedger(Amethyst.instance.signerPermissionStore) }
// Per-applet sandboxed key-value store (namespaced by coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext) }
private val incoming by lazy { Messenger(Handler(Looper.getMainLooper(), ::handleMessage)) }
// The broker for the current account, rebuilt only on account switch (see broker()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
// Live relay subscriptions, keyed by the applet's subId; reads the current account live.
private val liveSubscriptions = NappletLiveSubscriptions { Amethyst.instance.sessionManager.loggedInAccount() }
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
// Streams identity.changed pushes (account switch / connect / disconnect) to a watching applet.
private val identityWatch =
NappletIdentityWatch(scope) {
Amethyst.instance.sessionManager.accountContent
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
}
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
// A UID check here would be useless: onBind() runs once (the binder is cached and reused for all
// later clients) and outside any binder transaction, so getCallingUid() returns our own UID anyway.
override fun onBind(intent: Intent?): IBinder? = incoming.binder
override fun onDestroy() {
liveSubscriptions.closeAll()
identityWatch.stop()
// Drop any foreground holds this broker still owns so they don't leak past the service.
synchronized(foregroundLeases) {
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
foregroundLeases.clear()
}
scope.cancel()
super.onDestroy()
}
// Sandbox surfaces (full-screen :napplet hosts) currently reporting themselves foreground, mapped to
// the last time each renewed its lease (monotonic elapsedRealtime). While this map is non-empty the
// main process is held resumed (Tor/relays/AUTH up) via SandboxForegroundHold — the napplet host
// lives in :napplet and can't touch that lifecycle itself, so it signals over IPC.
//
// The host re-sends its foreground report on a heartbeat while genuinely resumed. We key on the
// launch token, so a repeated report just refreshes the timestamp (idempotent, no double-acquire).
// If a host's process dies while foreground it can't send its onPause "false", so the heartbeats
// simply stop and [foregroundLeaseWatchdog] reaps the stale lease — bounding any such leak to one
// [FOREGROUND_LEASE_TTL_MS] window instead of holding the network up forever.
private val foregroundLeases = HashMap<String, Long>()
private var foregroundLeaseWatchdog: Job? = null
private fun handleMessage(msg: Message): Boolean {
// A sandbox surface (full-screen :napplet host) entered, renewed, or left the foreground. Hold the
// main process resumed while at least one is foreground, so opening it doesn't tear down Tor/relays.
if (msg.what == NappletIpc.MSG_SET_FOREGROUND) {
val data = msg.data ?: return true
val token = data.getString(NappletIpc.KEY_LAUNCH_TOKEN) ?: return true
val foreground = data.getBoolean(NappletIpc.KEY_FOREGROUND, false)
synchronized(foregroundLeases) {
if (foreground) {
val firstReport = !foregroundLeases.containsKey(token)
// A foreground lease just pins Tor/relays (no key access), but a misbehaving sandbox
// could still spam distinct keys to keep the network up. Bound the damage: refuse new
// lease keys past the cap. Real usage holds only a handful of foreground surfaces.
if (firstReport && foregroundLeases.size >= MAX_FOREGROUND_LEASES) {
Log.w("NappletBrokerService", "Foreground lease cap reached; ignoring new lease $token")
return true
}
foregroundLeases[token] = SystemClock.elapsedRealtime()
if (firstReport) {
SandboxForegroundHold.acquire()
ensureForegroundLeaseWatchdog()
}
} else if (foregroundLeases.remove(token) != null) {
SandboxForegroundHold.release()
}
}
return true
}
// The direct-WebView browser relays a successfully loaded page; record it in the visit history
// (main process only). Only clean page-finishes reach here, so misspellings never get recorded.
if (msg.what == NappletIpc.MSG_RECORD_HISTORY) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_HISTORY_URL)?.takeIf { it.isNotBlank() } ?: return true
BrowserHistoryRegistry.init(applicationContext)
BrowserHistoryRegistry.record(url, data.getString(NappletIpc.KEY_HISTORY_TITLE).orEmpty())
return true
}
// The direct-WebView browser relays a favicon captured from the loaded page; store it by host.
if (msg.what == NappletIpc.MSG_RECORD_ICON) {
val data = msg.data ?: return true
val host = data.getString(NappletIpc.KEY_ICON_HOST)?.takeIf { it.isNotBlank() } ?: return true
val bytes = data.getByteArray(NappletIpc.KEY_ICON_BYTES) ?: return true
BrowserIconRegistry.init(applicationContext)
BrowserIconRegistry.record(host, bytes)
return true
}
// The direct-WebView browser requests a favorite toggle for the current URL (main process only).
if (msg.what == NappletIpc.MSG_TOGGLE_WEB_FAVORITE) {
val data = msg.data ?: return true
val url = data.getString(NappletIpc.KEY_FAVORITE_URL)?.takeIf { it.isNotBlank() } ?: return true
val label = data.getString(NappletIpc.KEY_FAVORITE_LABEL).orEmpty().ifBlank { url }
FavoriteAppsRegistry.init(applicationContext)
val id = "url:$url"
if (FavoriteAppsRegistry.isFavorite(id)) {
FavoriteAppsRegistry.remove(id)
} else {
FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label, System.currentTimeMillis()))
}
return true
}
// The direct-WebView browser relays its per-host Tor choice; persist it (main process only).
if (msg.what == NappletIpc.MSG_SET_WEB_TOR) {
val data = msg.data ?: return true
val host = data.getString(NappletIpc.KEY_WEB_HOST)?.takeIf { it.isNotBlank() } ?: return true
WebAppNetworkRegistry.init(applicationContext)
WebAppNetworkRegistry.set(host, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true))
return true
}
// The sandbox relays the user's per-site network choice; persist it against the trusted
// coordinate the launch token resolves to (the sandbox can't state its own coordinate).
if (msg.what == NappletIpc.MSG_SET_NETWORK_MODE) {
val data = msg.data ?: return true
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN)) ?: return true
NappletNetworkRegistry.init(applicationContext)
NappletNetworkRegistry.set(session.identity.coordinate, data.getBoolean(NappletIpc.KEY_NETWORK_USE_TOR, true))
return true
}
// A running full-screen sandbox surface asks to open its editable permission screen. The sandbox
// can't state its own coordinate, so a napplet/nsite sends its launch token (resolved here to the
// trusted coordinate) and a browser sends its visited origin (keyed as `browser:<origin>`). Open
// the main activity at that Connected Apps detail.
if (msg.what == NappletIpc.MSG_OPEN_PERMISSIONS) {
val data = msg.data ?: return true
val coordinate =
data.getString(NappletIpc.KEY_LAUNCH_TOKEN)?.let { NappletLaunchRegistry.resolve(it)?.identity?.coordinate }
?: data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() }?.let { "browser:$it" }
?: return true
openConnectedAppDetail(coordinate)
return true
}
// Browser mode mints a fresh launch token per visited origin, so NIP-07 consent is scoped to the
// one site the request came from. The origin is the trusted source origin the WebView reported
// (the sandbox can't forge it), and the synthetic identity keys the permission ledger per host.
if (msg.what == NappletIpc.MSG_MINT_BROWSER_TOKEN) {
val data = msg.data ?: return true
val replyTo = msg.replyTo ?: return true
val origin = data.getString(NappletIpc.KEY_BROWSER_ORIGIN)?.takeIf { it.isNotBlank() } ?: return true
val identity = NappletIdentity(authorPubKey = BROWSER_IDENTITY_AUTHOR, identifier = origin)
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY))
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
Bundle().apply {
putString(NappletIpc.KEY_BROWSER_ORIGIN, origin)
putString(NappletIpc.KEY_LAUNCH_TOKEN, token)
}
}
runCatching { replyTo.send(response) }
return true
}
if (msg.what != NappletIpc.MSG_REQUEST) return false
val data = msg.data ?: return true
val replyTo = msg.replyTo ?: return true
val requestId = data.getString(NappletIpc.KEY_REQUEST_ID) ?: return true
val payload = data.getString(NappletIpc.KEY_PAYLOAD) ?: return true
val requestType = runCatching { NappletProtocolJson.readType(payload) }.getOrNull() ?: "napplet"
// Resolve the launch token to the trusted identity + declared set. The sandbox never states
// its own coordinate, so a compromised :napplet process can only ever act as the napplet it
// was launched as (it holds only its own token). An unknown token = no session; refuse.
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN))
if (session == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
return true
}
val identity = session.identity
val declared = session.declared
scope.launch {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
// decision (it stays wire-identical with the future desktop host). This service only supplies
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
val broker = broker()
if (broker == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
is NappletRequestRouter.Outcome.OpenSubscription -> liveSubscriptions.open(outcome.subId, outcome.filters) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
}
}
return true
}
/**
* Periodically reaps foreground leases that stopped renewing — the signature of a `:napplet` host
* process that died (or was killed) while foreground, so it never sent its onPause "false". Each
* reaped lease releases its [SandboxForegroundHold] so the network isn't held up forever by a host
* that's already gone. Runs only while at least one lease exists; cancelled with [scope] on destroy.
*/
private fun ensureForegroundLeaseWatchdog() {
if (foregroundLeaseWatchdog != null) return
foregroundLeaseWatchdog =
scope.launch {
while (true) {
delay(FOREGROUND_LEASE_CHECK_MS)
synchronized(foregroundLeases) {
val now = SystemClock.elapsedRealtime()
val iterator = foregroundLeases.entries.iterator()
while (iterator.hasNext()) {
val entry = iterator.next()
if (now - entry.value > FOREGROUND_LEASE_TTL_MS) {
Log.w("NappletBrokerService", "Foreground lease ${entry.key} expired (host process gone?); releasing hold")
iterator.remove()
SandboxForegroundHold.release()
}
}
}
}
}
}
/**
* The broker for the *currently* signed-in account, cached and rebuilt only when the account
* changes (reference identity). The gateways capture the account and read its flows live, so a
* cached broker stays correct across requests without per-request allocation.
*/
@Synchronized
private fun broker(): NappletBroker? {
val account = Amethyst.instance.sessionManager.loggedInAccount() ?: return null
cachedBroker?.let { (acc, broker) -> if (acc === account) return broker }
val broker =
AccountNappletGateways(
account = account,
context = applicationContext,
ledger = ledger,
storage = storage,
// Per-applet Tor decision (see NappletResourceFetcher): the shared manager routes
// through Tor when asked + active, and falls back to clearnet otherwise.
httpClient = { useProxy -> Amethyst.instance.okHttpClients.getHttpClient(useProxy) },
signerLedger = signerLedger,
).broker()
cachedBroker = account to broker
return broker
}
/**
* Brings the main activity (a `singleInstance`) forward at the Connected Apps detail for [coordinate],
* via the in-process `connectedapp?coordinate=` deep link that [com.vitorpamplona.amethyst.ui.uriToRoute]
* resolves. Used when a full-screen sandbox surface taps "Manage permissions".
*/
private fun openConnectedAppDetail(coordinate: String) {
val intent =
Intent(applicationContext, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
data = Uri.parse("nostr:connectedapp?coordinate=" + Uri.encode(coordinate))
}
runCatching { applicationContext.startActivity(intent) }
.onFailure { Log.w("NappletBrokerService", "Could not open Connected Apps detail", it) }
}
private fun reply(
replyTo: Messenger,
requestId: String,
payload: String,
) {
val response =
Message.obtain(null, NappletIpc.MSG_RESPONSE).apply {
data =
Bundle().apply {
putString(NappletIpc.KEY_REQUEST_ID, requestId)
putString(NappletIpc.KEY_PAYLOAD, payload)
}
}
try {
replyTo.send(response)
} catch (e: RemoteException) {
Log.w("NappletBrokerService", "Applet host went away before reply could be delivered", e)
}
}
/** Sends an unsolicited push (a `relay.event`/`relay.eose` envelope) for the host to forward verbatim. */
private fun push(
replyTo: Messenger,
payload: String,
) {
val message =
Message.obtain(null, NappletIpc.MSG_PUSH).apply {
data = Bundle().apply { putString(NappletIpc.KEY_PAYLOAD, payload) }
}
try {
replyTo.send(message)
} catch (e: RemoteException) {
Log.w("NappletBrokerService", "Applet host went away before push could be delivered", e)
}
}
companion object {
/**
* Sentinel "author" for a browser-mode per-origin identity. The real key is the visited origin,
* carried in the identity's identifier (which the consent dialog shows); this constant only fills
* the coordinate's author slot so each origin keys the permission ledger separately as
* `browser:<origin>`. It is never treated as a real pubkey.
*/
private const val BROWSER_IDENTITY_AUTHOR = "browser"
/**
* How long a foreground lease stays valid without a renewing heartbeat. A live host re-reports
* every [NappletHostActivity.FOREGROUND_HEARTBEAT_MS][com.vitorpamplona.amethyst.napplethost.NappletHostActivity]
* (well under this), so only a host that's actually gone lets its lease age past it. Sized to
* tolerate a couple of missed/delayed heartbeats while still reaping a dead host's hold promptly.
*/
private const val FOREGROUND_LEASE_TTL_MS = 90_000L
/** How often [ensureForegroundLeaseWatchdog] sweeps for stale leases. */
private const val FOREGROUND_LEASE_CHECK_MS = 20_000L
/**
* Cap on concurrent foreground leases. A foreground lease only keeps Tor/relays up (no key
* access), but this bounds how long a misbehaving sandbox can pin the network by spamming
* distinct lease keys. Comfortably above any realistic number of foreground sandbox surfaces.
*/
private const val MAX_FOREGROUND_LEASES = 8
}
}
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
/** Localized display name for a capability, shared by the consent dialog and the permissions screen. */
@StringRes
fun NappletCapability.labelRes(): Int =
when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys
NappletCapability.RELAY -> R.string.napplet_cap_relay
NappletCapability.STORAGE -> R.string.napplet_cap_storage
NappletCapability.VALUE -> R.string.napplet_cap_value
NappletCapability.RESOURCE -> R.string.napplet_cap_resource
NappletCapability.UPLOAD -> R.string.napplet_cap_upload
NappletCapability.THEME -> R.string.napplet_cap_theme
NappletCapability.NOTIFY -> R.string.napplet_cap_notify
NappletCapability.INC -> R.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
@StringRes
fun NappletCapability.descriptionRes(): Int =
when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc
NappletCapability.THEME -> R.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc
NappletCapability.INC -> R.string.napplet_cap_inc_desc
}
@@ -0,0 +1,288 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
class NappletConnectActivity : ComponentActivity() {
private var token: String? = null
private var decided = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN)
this.token = token
val info = token?.let { NappletConnectCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
}
setContent {
AmethystTheme {
NappletConnectScreen(
info = info,
onConnect = { policy ->
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
finish()
},
onBlock = {
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
finish()
},
onCancel = {
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
finish()
},
)
}
}
}
override fun finish() {
if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
super.finish()
}
}
@Composable
private fun NappletConnectScreen(
info: NappletConnectInfo,
onConnect: (AppSignerPolicy) -> Unit,
onBlock: () -> Unit,
onCancel: () -> Unit,
) {
var selected by remember { mutableStateOf(AppSignerPolicy.REASONABLE) }
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.9f
Dialog(
onDismissRequest = onCancel,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 24.dp),
) {
// Centered header: icon + app name + connect subtitle
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_connect_subtitle),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(12.dp))
Text(
stringResource(R.string.napplet_connect_how_handle),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
)
Spacer(Modifier.height(8.dp))
// Trust level options
Column(
modifier = Modifier.padding(horizontal = 24.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
icon = "",
label = stringResource(R.string.napplet_policy_full_trust),
description = stringResource(R.string.napplet_policy_full_trust_desc),
onClick = { selected = AppSignerPolicy.FULL_TRUST },
)
PolicyOption(
selected = selected == AppSignerPolicy.REASONABLE,
icon = "👍",
label = stringResource(R.string.napplet_policy_reasonable),
description = stringResource(R.string.napplet_policy_reasonable_desc),
onClick = { selected = AppSignerPolicy.REASONABLE },
)
PolicyOption(
selected = selected == AppSignerPolicy.PARANOID,
icon = "🕶",
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.napplet_connect_button))
}
}
OutlinedButton(
onClick = onBlock,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(
stringResource(R.string.napplet_connect_block, info.domain),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@Composable
private fun PolicyOption(
selected: Boolean,
icon: String,
label: String,
description: String,
onClick: () -> Unit,
) {
val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
val bgColor = if (selected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else MaterialTheme.colorScheme.surface
Surface(
modifier =
Modifier
.fillMaxWidth()
.border(width = if (selected) 2.dp else 1.dp, color = borderColor, shape = RoundedCornerShape(12.dp))
.clickable(onClick = onClick),
shape = RoundedCornerShape(12.dp),
color = bgColor,
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(icon, style = MaterialTheme.typography.headlineSmall)
Column(modifier = Modifier.weight(1f)) {
Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface)
Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
if (selected) {
Icon(
symbol = MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
@@ -0,0 +1,86 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the "Connect to Nostr" dialog needs to render. */
data class NappletConnectInfo(
val appletTitle: String,
val coordinate: String,
val domain: String,
val iconUrl: String? = null,
)
/**
* Bridges the broker to the "Connect to Nostr" first-connect UI. Suspends in [requestConnect];
* the Activity resolves the deferred with the user's choice.
* A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant.
*/
object NappletConnectCoordinator {
private class Pending(
val info: NappletConnectInfo,
val deferred: CompletableDeferred<AppConnectResult>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConnect(
context: Context,
info: NappletConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<AppConnectResult>()
pending[token] = Pending(info, deferred)
context.startActivity(
Intent(context, NappletConnectActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token),
)
return try {
deferred.await()
} finally {
pending.remove(token)
}
}
fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
fun complete(
token: String,
result: AppConnectResult,
) {
pending[token]?.deferred?.complete(result)
}
fun cancel(token: String) {
pending[token]?.deferred?.complete(AppConnectResult.Cancelled)
}
const val EXTRA_TOKEN = "napplet_connect_token"
}
@@ -0,0 +1,241 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
* The capability-consent dialog, shown in the **main** process (the only place trusted to
* make a grant). It renders the [NappletConsentInfo] for a pending request and reports the
* user's [GrantState] back through [NappletConsentCoordinator]. The untrusted applet never
* sees or drives this UI.
*/
class NappletConsentActivity : ComponentActivity() {
private var token: String? = null
private var decided = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val token = intent.getStringExtra(NappletConsentCoordinator.EXTRA_TOKEN)
this.token = token
val info = token?.let { NappletConsentCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
}
setContent {
AmethystTheme {
NappletConsentDialog(
info = info,
onDecision = { grant ->
decided = true
NappletConsentCoordinator.complete(token, grant)
finish()
},
onDismiss = {
decided = true
NappletConsentCoordinator.cancel(token)
finish()
},
)
}
}
}
override fun finish() {
// If the system tears us down before the user chose, fail closed.
if (!decided) token?.let { NappletConsentCoordinator.cancel(it) }
super.finish()
}
}
@Composable
private fun NappletConsentDialog(
info: NappletConsentInfo,
onDecision: (GrantState) -> Unit,
onDismiss: () -> Unit,
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(rememberScrollState())
.padding(vertical = 24.dp),
) {
// Centered header: icon + app name + capability category + coordinate
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
info.capabilityLabel,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
// Operation detail box (may include content preview)
if (info.operationSummary.isNotBlank()) {
Spacer(Modifier.height(12.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
SelectionContainer {
Text(
info.operationSummary,
modifier = Modifier.padding(12.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
if (info.allowAlways) {
Button(
onClick = { onDecision(GrantState.ALLOW_ALWAYS) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
OutlinedButton(
onClick = { onDecision(GrantState.ALLOW_ONCE) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_once))
}
} else {
Button(
onClick = { onDecision(GrantState.ALLOW_ONCE) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_once))
}
}
Spacer(Modifier.height(4.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { onDecision(GrantState.ASK) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(
stringResource(R.string.napplet_consent_not_now),
style = MaterialTheme.typography.bodyMedium,
)
}
OutlinedButton(
onClick = { onDecision(GrantState.DENY) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(
stringResource(R.string.napplet_consent_deny_always),
style = MaterialTheme.typography.bodyMedium,
)
}
}
}
}
}
@@ -0,0 +1,95 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the consent dialog needs to describe what an applet is asking permission to do. */
data class NappletConsentInfo(
val appletTitle: String,
val coordinate: String,
val capabilityLabel: String,
val operationSummary: String,
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
val allowAlways: Boolean,
val iconUrl: String? = null,
)
/**
* Bridges the main-process broker to the consent UI. The broker suspends in
* [requestConsent]; this launches [NappletConsentActivity], holds the pending decision keyed
* by a one-time token, and resolves it when the activity reports the user's choice.
*
* A dismissed dialog resolves to [GrantState.ASK] — i.e. "not authorized, ask again next
* time" — so closing the prompt never silently grants nor permanently blocks.
*/
object NappletConsentCoordinator {
private class Pending(
val info: NappletConsentInfo,
val deferred: CompletableDeferred<GrantState>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletConsentInfo,
): GrantState {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<GrantState>()
pending[token] = Pending(info, deferred)
val intent =
Intent(context, NappletConsentActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token)
context.startActivity(intent)
return try {
deferred.await()
} finally {
pending.remove(token)
}
}
/** Called by [NappletConsentActivity] to render the prompt. */
fun infoFor(token: String): NappletConsentInfo? = pending[token]?.info
/** Called by [NappletConsentActivity] with the user's decision. */
fun complete(
token: String,
grant: GrantState,
) {
pending[token]?.deferred?.complete(grant)
}
/** Called when the dialog is dismissed without a choice — fails closed (no grant). */
fun cancel(token: String) {
pending[token]?.deferred?.complete(GrantState.ASK)
}
const val EXTRA_TOKEN = "napplet_consent_token"
}
@@ -0,0 +1,106 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
* shows — the applet's title, the capability label, and a per-operation summary (e.g. a note preview
* or a sat amount). Localized via app resources; holds only a [Context], no account state.
*/
class NappletConsentSummary(
private val context: Context,
) {
fun info(
identity: NappletIdentity,
capability: NappletCapability,
request: NappletRequest,
): NappletConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
)
}
private fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read)
is NappletRequest.Publish -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_publish, request.kind)
} else {
context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n$preview"
}
}
is NappletRequest.SignEvent -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_sign, request.kind)
} else {
context.getString(R.string.napplet_consent_sign, request.kind) + "\n$preview"
}
}
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
context.getString(R.string.napplet_consent_storage)
is NappletRequest.NotifyCreate -> {
val preview = request.title.take(80).trim()
if (preview.isEmpty()) context.getString(R.string.napplet_consent_notify) else context.getString(R.string.napplet_consent_notify) + "\n$preview"
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
}
}
@@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
/**
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
* collects the active user's public key ([pubKey]) and, on every *subsequent* change (the current
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
* (or `""` when no account is signed in) to the caller-supplied sink.
*
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the
* router confirmed the applet declared the IDENTITY capability.
*/
class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: () -> Flow<String>,
) {
private var job: Job? = null
fun start(push: (String) -> Unit) {
stop()
job =
scope.launch {
pubKey()
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
}
}
fun stop() {
job?.cancel()
job = null
}
}
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Messenger
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
/**
* The NAP `inc` bus: a topic pub/sub that fans an `inc.emit` out to **other** napplet sessions
* subscribed to that topic as an `inc.event` push (never echoing back to the sender). Lives in the
* main-process broker service, which is the single point every sandbox session binds to, so it can
* route between them.
*
* Subscribers are keyed by their reply [Messenger] (one per live napplet host). A napplet must have
* declared the `inc` capability to subscribe or emit (enforced by the router before we get here).
*
* NOTE: Amethyst runs napplets **foreground-only, one at a time**, so in practice two sessions rarely
* overlap and cross-napplet delivery is usually a no-op — but the bus is correct if they ever do.
* The bus is app-wide (not scoped per author), so it deliberately allows different napplets to talk;
* a future refinement could namespace topics by author if cross-napplet isolation is wanted.
*/
class NappletIncBus(
private val deliver: (Messenger, String) -> Unit,
) {
// topic -> the reply Messengers of the napplet sessions subscribed to it.
private val subscribers = HashMap<String, MutableSet<Messenger>>()
@Synchronized
fun subscribe(
who: Messenger,
topic: String,
) {
subscribers.getOrPut(topic) { mutableSetOf() }.add(who)
}
@Synchronized
fun unsubscribe(
who: Messenger,
topic: String,
) {
subscribers[topic]?.let { set ->
set.remove(who)
if (set.isEmpty()) subscribers.remove(topic)
}
}
/** Delivers an `inc.event` for [topic] to every subscriber except [from] (no self-echo). */
@Synchronized
fun emit(
from: Messenger,
sender: String,
topic: String,
payloadRaw: String,
) {
val set = subscribers[topic] ?: return
if (set.isEmpty()) return
val envelope = NappletProtocolJson.encodeIncEvent(topic, payloadRaw, sender)
set.filter { it != from }.forEach { deliver(it, envelope) }
}
/** Drops [who] from every topic (e.g. when its napplet host goes away). */
@Synchronized
fun removeAll(who: Messenger) {
val empties = mutableListOf<String>()
for ((topic, set) in subscribers) {
set.remove(who)
if (set.isEmpty()) empties.add(topic)
}
empties.forEach { subscribers.remove(it) }
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.SecureRandom
/**
* Main-process registry that binds a sandbox launch to its **trusted** identity + declared capability
* set, addressed by an unguessable launch token.
*
* Why: the broker (main process) must know *which* napplet a brokered request belongs to, but the
* request travels up from the sandboxed `:napplet` process, which is the very thing we don't fully
* trust (a WebView/renderer escape runs code there). If the identity rode along inside each IPC
* message, a compromised sandbox could simply claim another napplet's coordinate and read/act as it.
*
* Instead, [NappletLauncher] (main process) mints a random token here at launch time, hands only that
* token to the sandbox, and the broker resolves it back to the identity it was registered with. The
* sandbox only ever holds *its own* token, so even a fully compromised `:napplet` process can act as
* nothing but the napplet it was launched as.
*
* Both the launcher and [NappletBrokerService] run in the main process, so they share this singleton.
*/
object NappletLaunchRegistry {
data class Session(
val identity: NappletIdentity,
val declared: Set<NappletCapability>,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
// active napplet always re-touches its token, so only stale sessions are ever evicted.
private const val MAX_SESSIONS = 128
private val sessions =
object : LinkedHashMap<String, Session>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: Map.Entry<String, Session>) = size > MAX_SESSIONS
}
private val secureRandom = SecureRandom()
@Synchronized
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared)
return token
}
@Synchronized
fun resolve(token: String?): Session? = token?.let { sessions[it] }
@Synchronized
fun unregister(token: String?) {
token?.let { sessions.remove(it) }
}
}
@@ -0,0 +1,161 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
/**
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
* the verified manifest data the host needs to render and broker for the applet is passed —
* the declared `path → hash` map, the Blossom servers, the applet's identity coordinate, the
* declared capabilities, and a display title. No account state crosses into the sandbox process.
*/
object NappletLauncher {
/** Opens a NIP-5D napplet, forwarding its declared capabilities to the broker. */
fun launch(
context: Context,
manifest: NappletManifest,
authorPubKey: HexKey,
identifier: String,
) = launch(
context = context,
paths = manifest.paths(),
servers = manifest.servers(),
authorPubKey = authorPubKey,
identifier = identifier,
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
requires = manifest.requires(),
)
/**
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite —
* the broker then refuses every capability, so the site renders as inert static content.
*/
fun launch(
context: Context,
paths: List<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
profile: HostProfile = HostProfile.NAPPLET,
) {
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtras(params)
if (context !is android.app.Activity) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
/**
* Builds the verified launch parameters — minting the launch token, augmenting the Blossom server
* set, resolving the per-site Tor choice and capability labels — as a [Bundle] keyed by the
* [NappletHostContract] EXTRA_* names. Used both for the activity intent (above) and for the
* embedded [com.vitorpamplona.amethyst.napplethost.NappletHostService] session (carried over
* Messenger), so the two host paths launch from identical, main-process-minted parameters.
*/
fun buildLaunchParams(
context: Context,
paths: List<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
profile: HostProfile,
): Bundle {
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
// we already hold it, so a blob the manifest's servers dropped can still be fetched. The
// host re-verifies every blob's sha256, so a wrong/extra server can never inject content.
val authorBlossomServers =
runCatching {
(LocalCache.getAddressableNoteIfExists(BlossomServersEvent.createAddressTag(authorPubKey))?.event as? BlossomServersEvent)?.servers()
}.getOrNull().orEmpty()
val allServers = (servers + authorBlossomServers).distinct()
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val declared = profile.declaredCapabilities(requires)
val launchToken = NappletLaunchRegistry.register(identity, declared)
// Resolve the per-site network choice (Tor default; a site can be opted out to the open web).
// Locked napplets always keep Tor for their blob fetches — only nSites expose the toggle.
NappletNetworkRegistry.init(context.applicationContext)
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
when (themeType) {
ThemeType.DARK -> "DARK"
ThemeType.LIGHT -> "LIGHT"
ThemeType.SYSTEM -> {
val nightMask = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
if (nightMask == Configuration.UI_MODE_NIGHT_YES) "DARK" else "LIGHT"
}
}
return Bundle().apply {
putStringArrayList(NappletHostContract.EXTRA_PATHS, ArrayList(paths.map { it.path }))
putStringArrayList(NappletHostContract.EXTRA_HASHES, ArrayList(paths.map { it.hash }))
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
putString(NappletHostContract.EXTRA_TITLE, title)
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
putString(NappletHostContract.EXTRA_LAUNCH_TOKEN, launchToken)
putInt(NappletHostContract.EXTRA_PROXY_PORT, proxyPort)
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
}
}
}
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
/**
* The registry of live relay subscriptions an applet has open, keyed by its `subId`. Each entry
* holds the exact [INostrClient] that opened it, so teardown unsubscribes from the right account
* even after an account switch, plus an EOSE latch so a multi-relay subscription emits a single
* `relay.eose`. Encodes the `relay.event`/`relay.eose`/`relay.closed` pushes and hands them to the
* caller-supplied sink — it never touches the transport itself.
*
* [account] is read live (so it always targets the currently signed-in account); [open] is reached
* only after the broker authorized the subscription (RELAY consent).
*/
class NappletLiveSubscriptions(
private val account: () -> Account?,
) {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
private class LiveSub(
val clientSubId: String,
val client: INostrClient,
) {
val eoseSent = AtomicBoolean(false)
}
/**
* Opens a live relay subscription for [nappletSubId], streaming `relay.event`/`relay.eose`/
* `relay.closed` envelopes to [push] as events arrive. Replaces any existing subscription for
* the same id. With no account/relays/filters it pushes a single empty EOSE to close it.
*/
fun open(
nappletSubId: String,
filters: List<Filter>,
push: (String) -> Unit,
) {
val account = account()
val relays = account?.homeRelays?.flow?.value ?: emptySet()
if (account == null || filters.isEmpty() || relays.isEmpty()) {
push(NappletProtocolJson.encodeRelayEose(nappletSubId))
return
}
close(nappletSubId)
// liveSeq guarantees a unique client subId, so a rapid re-open of the same applet subId
// can't collide with the subscription it's replacing.
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
liveSubs[nappletSubId] = sub
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
// A subscription fans out to several relays; collapse their EOSEs into the single
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
}
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
}
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
fun close(nappletSubId: String) {
val sub = liveSubs.remove(nappletSubId) ?: return
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
/** Tears down every open subscription (service teardown). */
fun closeAll() {
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
liveSubs.clear()
}
}
@@ -0,0 +1,73 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent
import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent
import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent
/**
* Looks up the best-effort human title and icon URL for a napplet or nsite from the local cache.
* Falls back to the d-identifier or [untitled] when no manifest is cached.
*/
fun resolveNappletMeta(
author: String,
identifier: String,
untitled: String,
): Pair<String, String?> {
val events =
Amethyst.instance.cache
.filter(
Filter(
kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND, RootSiteEvent.KIND, NamedSiteEvent.KIND),
authors = listOf(author),
),
).mapNotNull { it.event }
val match =
events.firstOrNull { ev ->
when (ev) {
is NamedNappletEvent -> ev.identifier() == identifier
is RootNappletEvent -> identifier.isEmpty()
is NamedSiteEvent -> ev.identifier() == identifier
is RootSiteEvent -> identifier.isEmpty()
else -> false
}
}
val title =
when (match) {
is NappletManifest -> match.title()
is RootSiteEvent -> match.title()
is NamedSiteEvent -> match.title()
else -> null
}?.ifBlank { null } ?: identifier.ifBlank { untitled }
val iconUrl =
when (match) {
is NappletManifest -> match.icon()
is RootSiteEvent -> match.icon()
is NamedSiteEvent -> match.icon()
else -> null
}?.ifBlank { null }
return title to iconUrl
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
private val Context.nappletNetworkDataStore by preferencesDataStore(name = "napplet_network")
/**
* Per-nSite network-routing preference: whether a site's traffic goes through **Tor** (the default)
* or over the **open web**. Keyed by the site's coordinate (`author:identifier`), like the permission
* ledger, so the choice survives across launches and routine code updates.
*
* When Tor is active, nSites route through it by default; a user can opt a specific site out (e.g. a
* site that breaks or is unusably slow over Tor) from the sandbox top bar. "Open web" makes
* *everything* for that site direct — both its live web traffic and its blob fetches.
*
* An in-memory map is authoritative for the session ([NappletLauncher] reads it synchronously while
* building the launch intent) with write-through persistence. Absent = Tor (the safe default), so a
* site never silently starts on the open web before the on-disk preference has hydrated.
*
* Lives only in the **main process**: the launcher reads it, and [NappletBrokerService] writes it
* when the key-free `:napplet` sandbox relays a toggle over IPC. The sandbox never touches it.
*/
object NappletNetworkRegistry {
private const val OPEN_WEB = "OPEN"
private const val TOR = "TOR"
// coordinate -> useTor. Absent means "never chosen" -> Tor.
private val modes = ConcurrentHashMap<String, Boolean>()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@Volatile private var appContext: Context? = null
@Volatile private var hydration: Job? = null
/** Binds the app context and hydrates the on-disk preferences into memory. Idempotent. */
fun init(context: Context) {
if (appContext != null) return
val ctx = context.applicationContext
appContext = ctx
hydration =
scope.launch {
ctx.nappletNetworkDataStore.data.first().asMap().forEach { (key, value) ->
// putIfAbsent: never clobber a choice made in this session before hydration finished.
modes.putIfAbsent(key.name, value != OPEN_WEB)
}
}
}
/**
* Suspends until [init]'s on-disk hydration has finished, so [useTor] reflects the user's remembered
* per-site choices instead of the bare Tor default. The startup preloader awaits this before making
* any routing decision, so a site the user pinned to the open web isn't wrongly treated as Tor on a
* cold start. Returns at once once hydrated, or immediately if [init] was never called.
*/
suspend fun awaitReady() {
hydration?.join()
}
/** Whether [coordinate] routes through Tor. Defaults to true (Tor) for any site never set. */
fun useTor(coordinate: String): Boolean = modes[coordinate] ?: true
/** Records [useTor] for [coordinate] in memory and persists it. */
fun set(
coordinate: String,
useTor: Boolean,
) {
modes[coordinate] = useTor
val ctx = appContext ?: return
scope.launch {
ctx.nappletNetworkDataStore.edit { it[stringPreferencesKey(coordinate)] = if (useTor) TOR else OPEN_WEB }
}
}
}
@@ -0,0 +1,63 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.commons.napplet.NappletNotification
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
/**
* Per-coordinate registry of the notifications a napplet created via the NAP `notify` domain. Lives
* in the main process (the broker's process) and survives broker rebuilds on account switch, so a
* napplet's `notify.list`/`notify.dismiss` see the same set it created.
*
* Namespaced by applet coordinate, like [DataStoreNappletStorage]: a napplet can only ever see and
* dismiss its **own** notifications. In-memory only — notifications are ephemeral UI, not durable state.
*/
object NappletNotificationStore {
// coordinate -> (id -> notification), insertion-ordered so list() is stable.
private val byCoordinate = ConcurrentHashMap<String, LinkedHashMap<String, NappletNotification>>()
private val seq = AtomicLong(0)
fun create(
coordinate: String,
title: String,
body: String,
): String {
val id = "n${System.currentTimeMillis()}-${seq.incrementAndGet()}"
val map = byCoordinate.getOrPut(coordinate) { LinkedHashMap() }
synchronized(map) { map[id] = NappletNotification(id, title, body) }
return id
}
fun list(coordinate: String): List<NappletNotification> {
val map = byCoordinate[coordinate] ?: return emptyList()
return synchronized(map) { map.values.toList() }
}
fun dismiss(
coordinate: String,
id: String,
) {
val map = byCoordinate[coordinate] ?: return
synchronized(map) { map.remove(id) }
}
}
@@ -0,0 +1,325 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.utils.TimeUtils
class NappletSignerConsentActivity : ComponentActivity() {
private var token: String? = null
private var decided = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN)
this.token = token
val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
}
setContent {
AmethystTheme {
NappletSignerConsentDialog(
info = info,
onGrant = { grant ->
decided = true
NappletSignerConsentCoordinator.complete(token, grant)
finish()
},
onDismiss = {
decided = true
NappletSignerConsentCoordinator.cancel(token)
finish()
},
)
}
}
}
override fun finish() {
if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) }
super.finish()
}
}
@Composable
private fun NappletSignerConsentDialog(
info: NappletSignerConsentInfo,
onGrant: (SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
var showRawData by remember { mutableStateOf(false) }
var showMoreOptions by remember { mutableStateOf(false) }
val scrollState = rememberScrollState()
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Surface(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.heightIn(max = maxHeight),
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 6.dp,
) {
Column(
modifier =
Modifier
.verticalScroll(scrollState)
.padding(vertical = 24.dp),
) {
// Centered header: icon + title + description
Column(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val isBrowser = info.coordinate.startsWith("browser:")
FavoriteAppIcon(
app =
if (isBrowser) {
FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl)
} else {
FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl)
},
tint = MaterialTheme.colorScheme.onPrimaryContainer,
modifier = Modifier.size(56.dp),
)
Text(
info.appletTitle,
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
stringResource(R.string.napplet_consent_wants_to, info.operationSummary),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "" },
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
if (hasContent) {
Spacer(Modifier.height(12.dp))
Surface(
modifier =
Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(12.dp)) {
if (info.contentPreview.isNotBlank()) {
Text(
"${info.contentPreview}",
style = MaterialTheme.typography.bodySmall,
)
}
if (info.rawData.isNotBlank()) {
if (showRawData) {
Spacer(Modifier.height(8.dp))
Box(modifier = Modifier.horizontalScroll(rememberScrollState())) {
SelectionContainer {
Text(
info.rawData,
style =
MaterialTheme.typography.labelSmall.copy(
fontFamily = FontFamily.Monospace,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
softWrap = false,
)
}
}
}
TextButton(
onClick = { showRawData = !showRawData },
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp),
) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: always allow this op
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
// Secondary: allow just once
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_once))
}
// "More options" toggle: session and time-bound grants
TextButton(
onClick = { showMoreOptions = !showMoreOptions },
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
if (showMoreOptions) {
stringResource(R.string.napplet_consent_fewer_options)
} else {
stringResource(R.string.napplet_consent_more_options)
},
style = MaterialTheme.typography.bodyMedium,
)
Icon(
if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
}
}
if (showMoreOptions) {
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_session))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_24h))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_30d))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowAll) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_signer_allow_all))
}
}
Spacer(Modifier.height(4.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyOnce) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_once))
}
OutlinedButton(
onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary))
}
}
}
}
}
@@ -0,0 +1,94 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the per-operation consent dialog needs to render. */
data class NappletSignerConsentInfo(
val appletTitle: String,
val coordinate: String,
val op: NostrSignerOp,
val operationSummary: String,
/** Short excerpt shown in the dialog body (≤ 160 chars). */
val contentPreview: String,
/**
* Full raw content for the "See more" toggle — event JSON for sign/encrypt operations,
* decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose).
*/
val rawData: String = "",
val iconUrl: String? = null,
)
/**
* Bridges the broker to the per-operation signer consent UI.
* A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed.
*/
object NappletSignerConsentCoordinator {
private class Pending(
val info: NappletSignerConsentInfo,
val deferred: CompletableDeferred<SignerOpGrant>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletSignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
pending[token] = Pending(info, deferred)
context.startActivity(
Intent(context, NappletSignerConsentActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token),
)
return try {
deferred.await()
} finally {
pending.remove(token)
}
}
fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info
fun complete(
token: String,
grant: SignerOpGrant,
) {
pending[token]?.deferred?.complete(grant)
}
fun cancel(token: String) {
pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce)
}
const val EXTRA_TOKEN = "napplet_signer_consent_token"
}
@@ -0,0 +1,118 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
fun NostrSignerOp.label(context: Context): String =
when (this) {
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
}
/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
context: Context,
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): NappletSignerConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val summary = op.label(context)
val preview =
when (request) {
is NappletRequest.Publish -> request.content.take(160).trim()
is NappletRequest.SignEvent -> request.content.take(160).trim()
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
else -> ""
}
val rawData =
when (request) {
is NappletRequest.Publish ->
JacksonMapper.toJsonPretty(EventTemplate<Nothing>(TimeUtils.now(), request.kind, request.tags, request.content))
is NappletRequest.SignEvent ->
JacksonMapper.toJsonPretty(EventTemplate<Nothing>(request.createdAt, request.kind, request.tags, request.content))
is NappletRequest.PublishEncrypted -> {
val node = JacksonMapper.mapper.createObjectNode()
node.put("kind", request.kind)
node.put("recipient", request.recipient)
node.put("encryption", request.encryption)
val tagsNode = node.putArray("tags")
for (tag in request.tags) {
val tagNode = tagsNode.addArray()
for (item in tag) tagNode.add(item)
}
node.put("content", request.content)
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
}
else -> ""
}
return NappletSignerConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
op = op,
operationSummary = summary,
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
)
}
/** Creates a [NappletConnectInfo] for the first-connect dialog. */
fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
): NappletConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
host to BrowserIconRegistry.iconModelFor(host)
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val domain =
if (identity.authorPubKey == "browser") {
OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
} else {
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
}
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
}
@@ -0,0 +1,128 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Keeps Amethyst's **main** process in "resumed" resource posture while a full-screen sandbox surface
* (a `:napplet`-process browser/napplet/nSite host) is in the foreground.
*
* The problem it solves: opening one of those full-screen surfaces backgrounds `MainActivity`. Its
* Compose collectors (`ManageRelayServices` / `ManageWebOkHttp`) are lifecycle-aware, so they stop —
* and the underlying `WhileSubscribed` flows then scale everything down on their timers: Tor's port
* after ~2 s, the relay pool after ~30 s, dropping the relay AUTH sessions with it. The user is still
* actively using a Nostr surface (the sandbox brokers NIP-07 + relays back through this very process),
* so tearing the network down is wrong — the next signed request or relay read pays a full reconnect.
*
* The fix is a **ref-counted hold**, acquired only while such a surface is foreground. While the count
* is > 0 it subscribes to exactly the flows the resumed UI subscribes to — keeping Tor, the relay pool,
* and the AUTH sessions up. When the last surface leaves, it releases and normal background scaling
* resumes. It deliberately does *not* keep anything alive when nothing is foreground, so the intended
* battery/bandwidth scaling is preserved.
*
* Switching between several sandbox surfaces must not restart the network. Android tears the old
* surface down and brings the new one up as `old.onPause() → new.onResume()`, which would dip the
* count to 0 and back to 1. To keep the same collectors running straight through that dip, releasing to
* 0 only *schedules* the stop after a short [LINGER_MS] linger; a re-acquire within the window cancels
* the pending stop and the existing collectors are never even cancelled — so swapping between open
* napplets/nSites/browser apps holds Tor + relays + AUTH perfectly steady.
*
* Embedded favorite tabs need no hold: they render inside `MainActivity`, which stays resumed.
*
* Threading: [acquire]/[release] are `@Synchronized` and may be called from any thread (Activity main
* thread, broker IPC handler). The keep-alive collectors run on [AppModules.applicationIOScope].
*/
object SandboxForegroundHold {
/**
* How long the resource hold lingers after the last sandbox surface backgrounds, before the
* collectors are actually torn down. Sized to comfortably bridge an activity-to-activity handoff
* (`old.onPause → new.onResume`, plus the async IPC hop for `:napplet` surfaces) so switching
* between open surfaces never restarts Tor/relays. A genuine exit still releases after this short
* window, restoring normal background scaling.
*/
private const val LINGER_MS = 5_000L
private var holdCount = 0
// The live keep-alive collectors. Non-null whenever the resources are being held up (including
// during a linger after the count hit 0 but before the scheduled stop fires).
private var holdJob: Job? = null
// The pending teardown scheduled when the count hit 0; cancelled if a surface re-acquires first.
private var stopJob: Job? = null
@Synchronized
fun acquire() {
holdCount++
if (holdCount == 1) {
// Cancel any lingering teardown and keep the existing collectors running if they're still
// up (the common "switch between two surfaces" path) — only start fresh if fully released.
stopJob?.cancel()
stopJob = null
if (holdJob == null) start()
}
}
@Synchronized
fun release() {
if (holdCount == 0) return
holdCount--
if (holdCount == 0) scheduleStop()
}
private fun start() {
// Only ever invoked in the main process (broker + main-process activities); instance is set.
val app = Amethyst.instance
Log.d("SandboxForegroundHold", "Holding main-process resources up for a foreground sandbox surface")
holdJob =
app.applicationIOScope.launch {
// Mirror exactly what the resumed UI collects (AccountScreen's ManageRelayServices +
// ManageWebOkHttp): keeping these subscribed keeps the relay pool connected (and so the
// relay AUTH sessions), and — transitively, via the relay connector's combine and the
// okHttp proxy-port provider — keeps Tor up too.
launch { app.relayProxyClientConnector.relayServices.collect {} }
launch { app.okHttpClients.defaultHttpClient.collect {} }
launch { app.okHttpClients.defaultHttpClientWithoutProxy.collect {} }
}
}
private fun scheduleStop() {
stopJob?.cancel()
stopJob =
Amethyst.instance.applicationIOScope.launch {
delay(LINGER_MS)
synchronized(this@SandboxForegroundHold) {
// A surface may have re-acquired while we waited; only tear down if still released.
if (holdCount == 0) {
Log.d("SandboxForegroundHold", "No foreground sandbox surface for ${LINGER_MS}ms; releasing the resource hold")
holdJob?.cancel()
holdJob = null
}
stopJob = null
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More