Compare commits

...

323 Commits

Author SHA1 Message Date
Vitor Pamplona
72b8df7f6d Merge pull request #3663 from vitorpamplona/claude/sqlite-query-performance-bh2j27
SQLite query scaling: FTS contentless, tag-merge, pooled statements
2026-07-21 19:38:22 -04:00
Claude
d571bee394 fix(store): rank multi-filter search REQs + harden merge from branch audit
Two-reviewer adversarial audit of the branch found no correctness/data-loss/
crash bugs. This closes one gap and applies three robustness fixes:

- Multi-filter search ordering: a REQ whose filters all carry a search term
  (e.g. the client's search-across-kinds) was created_at-ordered via the union
  path. Now relevance-ordered — unionSubqueriesIfNeeded(projectRank) projects
  rank per branch, UNION ALL + GROUP BY row_id MIN(rank) dedups across branches
  keeping the best score. Only when every branch is a search branch; mixed
  search/non-search REQs and count/delete unions stay as before.
- prepareAuthorStreams/prepareTagStreams build cursors via buildStreams, which
  closes already-prepared statements if a later prepare throws (was: stranded
  checked-out, un-reset handles holding read locks in the pooled connection).
- Stream counts computed as Long so authors×kinds / values×kinds can't overflow
  Int back into the eligible band and route a huge fan-out into the merge.
- Renamed CachedStatement.finalize() -> finalizeStatement(): a no-arg finalize()
  is the JVM Object.finalize, risking a GC double-close of the native handle.

Tests: multi-filter search relevance + cross-branch dedup + count parity;
existing merge/cache/search suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 23:33:33 +00:00
Claude
52ba19d324 test(store): adversarial search-rank cases (two-tag+search, count parity, non-searchable delete)
From the branch audit: verifies the search-relevance path holds under a
two-tag-key filter (requires both tags, ranks by bm25, no duplicate rows),
that count(filter) matches query size under a limit smaller than the match set
(NIP-45), and that deleting a non-searchable event (no FTS row) fires the
contentless delete trigger against an absent rowid harmlessly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 23:12:59 +00:00
Claude
de114d1621 fix(store): relevance-order search+tag queries too, not just tag-free search
Any filter with a search term must be relevance-ranked (NIP-50), but only the
tag-free shape (makeSimpleSearch) was — search + a tag fell through to the
row-id-subquery path (prepareRowIDSubQueries/makeQueryIn), which ordered by
created_at.

prepareRowIDSubQueries gains projectRank: when a search filter joins event_fts,
it also projects the bm25 score as a `rank` column and cuts its LIMIT by rank
(most relevant, not newest); makeQueryIn(orderByRank) then presents the joined
result by that rank, created_at DESC as tie-break. Off by default, so
count/delete/union/negentropy (which must stay single-column and unranked) are
untouched. toSql wires it on whenever the filter carries a search term.

SearchRelevanceOrderTest adds a tag-scoped case (stronger-but-older outranks
weaker-but-newer, wrong-tag and non-matching excluded, limit cuts by score).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:54:47 +00:00
Claude
8e7b1b63be fix(store): order NIP-50 search by relevance (bm25), not created_at
NIP-50: results are returned "in descending order by quality of search result
... not by the usual .created_at", with the limit applied after the score. The
store sorted search by created_at DESC (pre-existing), so it returned the
newest matches rather than the best ones.

makeSimpleSearch (the search [+ kinds/authors/since/until] + limit shape) now
orders by FTS5 bm25 (ORDER BY event_fts.rank, created_at DESC as a tie-break).
Verified bm25 rank works on the contentless table through the join, and that a
stronger-but-older match outranks a weaker-but-newer one. The rarer
search+specific-tag shape and the negentropy snapshot still sort by created_at
(the row-id subquery can't carry rank; negentropy is a sync set) — documented.

This is a correctness fix, not a scaling one: bm25 scores every match, so
search latency still grows with the match set. Tests: SearchRelevanceOrderTest,
Fts5CapabilityProbe.bm25RankWorksOnContentlessTableInAJoin; QueryAssemblerTest
search plans updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:21:54 +00:00
Claude
e6a8f80826 fix(store): drop non-compliant rowid search ordering, keep FTS delete/size wins
searchOrderByRowId ordered NIP-50 search by the FTS rowid (ingestion order) to
get O(limit) search, but NIP-01's limit requires the newest events by
created_at. Once ingestion diverges from created_at (any historical sync) that
returns the wrong events under a limit — a spec violation — so the flag, its
QueryBuilder branch, and its test are removed. Search stays created_at-ordered;
corpus-independent search is an external-engine job, not this index.

The contentless + rowid=row_id schema stays for the reasons that don't touch
ordering: the delete trigger now seeks by rowid (O(log n)) instead of scanning
by an FTS column (O(n)) — measured ~78× faster at 8k rows and widening, on a
path every deletion hits — and the index is smaller. Benchmark reframed around
the delete win and the honest (unchanged) search cost; plan doc updated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 22:05:30 +00:00
Claude
c7670a52d4 perf(store): scale NIP-50 search and tag-watcher queries with corpus size
The scale-curve report showed the SQLite store degrading with corpus size on
NIP-50 search (~18×) and the large-IN tag watcher, while point reads stayed
flat. Three read/size changes (write path and index set unchanged):

- FTS: rebuild event_fts as a contentless FTS5 table (content='',
  contentless_delete=1) keyed by rowid = event_headers.row_id. Drops the
  stored content copy (smaller index); external-content can't hold the
  derived indexable text, so contentless is the correct primitive. Adds an
  opt-in searchOrderByRowId strategy flag: ORDER BY event_fts.rowid DESC
  early-terminates (O(limit), corpus-independent) at the cost of
  ingestion-order results — flat ~0.22 ms vs created_at's 4.26 ms at 200k
  (~19×). reindexAll ends with 'optimize'; the periodic optimize() folds in a
  bounded segment 'merge'. DB version 4->5 with a drop-and-rebuild migration.

- MergeQueryExecutor: extend the k-way merge to the tag path (kinds + #e IN
  [hundreds] + limit), one cursor per (value[,kind]) stream heap-merged to the
  limit, deduping events that carry several queried values. O(limit + streams)
  instead of collecting all matches and sorting.

- StatementCachingConnection: pool multiple handles per SQL so the merge's
  many concurrent identical-SQL cursors all hit the cache (previously only the
  first did) and repeated polls reuse their per-stream statements.

Tests: contentless migration (real v4 DB upgrade), rowid-order search, tag
merge correctness (incl. cross-stream dedup), statement pool, FTS5 capability
probe, and an FTS search-scaling benchmark. Plan in
quartz/plans/2026-07-21-sqlite-query-scaling.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XqGuBuSUsRudGerPqDuoKA
2026-07-21 21:45:42 +00:00
Vitor Pamplona
35e61e1d98 Merge pull request #3662 from vitorpamplona/fix/docs-license-badge-and-skill-md
docs: fix license badge and refresh stale SKILL.md
2026-07-21 17:38:07 -04:00
Vitor Pamplona
6730100853 docs: fix license badge and refresh stale SKILL.md
The README license badge label read "Apache-2.0" while LICENSE, PRIVACY.md
and every source header are MIT. Only the static label text was wrong (the
shields.io endpoint auto-detects), but it is the license on the front page.

SKILL.md had drifted from the codebase since the Kotlin DSL migration:

- All Gradle references pointed at Groovy `build.gradle` / `settings.gradle`;
  the repo is `.gradle.kts` throughout. Converted the snippets to Kotlin DSL
  and matched the repo's existing `getByName("release")` style.
- The plugins block listed `jetbrainsKotlinAndroid` (gone) and omitted
  `serialization` and `googleKsp`.
- compileSdk is 37, not 35. Added a pointer to libs.versions.toml so the
  number has a source of truth rather than drifting again.
- The client-tag section told readers to create
  `nip01Core/tags/clientTag/TagArrayBuilderExt.kt` and edit both `build()`
  functions in TextNoteEvent. That file already exists at
  `nip89AppHandlers/clientTag/`, and the tag is now applied centrally by the
  NostrSignerWithClientTag decorator — so rebranding is a one-constant edit
  to CLIENT_TAG_NAME.
- Default relays pointed at `quartz/src/main/java/...`, a path that does not
  exist in the KMP layout; they live in commons `defaults/`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 17:27:39 -04:00
Vitor Pamplona
69671d0009 Merge pull request #3660 from vitorpamplona/claude/nostr-relay-filters-mapping-bu95ib
Optimize relay query performance: cost-based driver selection, tag-author indexing, and fanout memoization
2026-07-21 15:35:38 -04:00
Claude
078758888a perf(relay): remove live-path allocations in LiveEventStore + FilterIndex
The remaining SmallReqFloorBenchmark waste, on the per-row replay, the
per-event live fanout, and the per-accepted-event index probe:

- LiveEventStore replay dedupe: a SeenIds holder with an inline lock
  replaces the local-fn-plus-lambda that allocated one closure per
  streamed row (and again per live delivery). Its HashSet is created
  empty so the JVM defers the backing table to the first add — a 0-row
  replay no longer allocates a 1024-slot table (was ~4 MB across the
  benchmark's 1000 idle subs).
- Live fanout serializes the event body once and passes it through
  onEachLive(event, body); RelaySession splices it into the per-sub
  frame prefix. An event matching N live subscriptions paid N identical
  Jackson passes before; now one. queryRaw's onEachLive signature gains
  the body arg (EventSourceBackend default serializes inline, no
  cross-sub memo, no regression). Measured: fanout 1->200 live subs
  0.50 ms (2.5 us/sub).
- FilterIndex holds subscribers in one persistent map per dimension, so
  candidatesFor (once per accepted ingest event) probes with the event's
  own fields and allocates no IdKey/AuthorKey/KindKey/TagKey wrappers;
  BucketKey now lives only in the rare register/unregister bookkeeping.

SmallReqFloorBenchmark grows a fanout stage (200 live subs, one submit)
to anchor the fanout number; it drives `live` directly and guards the
await with withTimeout so a future fanout regression fails fast.

Verified: quartz relay.server + FilterIndex suites (110 tests),
SmallReqFloorBenchmark, geode suite (126 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:38 +00:00
Claude
387bfe99ee perf(relay): drop per-op allocations on the frame + search paths
Three hot-path allocation cuts the SmallReqFloorBenchmark stages flagged:

- strippingSearchExtensions: index-loop guard returns the same list with
  zero allocation when no filter carries a search term (every non-search
  REQ/COUNT/snapshot, the overwhelming majority).
- EoseMessage/OkMessage: direct-buildString wire form on the escape-free
  fast path (EOSE per REQ, OK per publish), skipping the generic
  serializer's node tree; exotic subIds/reasons fall back. Shared
  isEscapeFreeAscii helper in WireJson.kt, mirroring NegMsgMessage.

Verified: quartz relay.server + message-frame suites (110 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 19:23:21 +00:00
Vitor Pamplona
4df7354ffc Merge pull request #3658 from vitorpamplona/fix/concord-messages-cold-boot
fix: Concord groups missing or slow on the Messages tab after a cold boot
2026-07-21 14:38:08 -04:00
Vitor Pamplona
a11de43400 Merge pull request #3659 from vitorpamplona/fix/persist-keypackage-algo-lists
fix: persist the MIP-00 key-package and favorite-algo-feeds lists
2026-07-21 14:37:54 -04:00
Vitor Pamplona
0a228a935b fix: persist the MIP-00 key-package and favorite-algo-feeds lists
AccountSettings declares 25 backup* fields and saves each on change, but two
were never wired into LocalPreferences — neither written to nor read back from
the encrypted prefs:

  - backupKeyPackageRelayList  (MIP-00, Marmot/MLS)
  - backupFavoriteAlgoFeedsList (kind 10090)

Both only ever existed for the lifetime of the process. Their consumers already
implement the restore-from-backup path — KeyPackageRelayListState's
normalizeKeyPackageRelayListWithBackup falls back to the field, and
FavoriteAlgoFeedsListState's init seeds the cache from it — so that code was
dead after every cold boot and the value read as empty until relays answered.

For the key-package list that matters beyond latency: it feeds
Account.publishRelaysFor(), which decides where this account's key packages are
published so others can add it to groups, and Account.updateKeyPackageRelays()
reads it as the *previous* list when computing an update.

Found by auditing all 25 backup* fields against their five LocalPreferences
wiring sites after the same gap turned up for the Concord community list; the
other 23, NIP-29's relay-group list included, are correctly wired.

Verified on device with a persist-then-cold-boot pair: the key-package list is
absent on the first boot and restores 1.6 s after the second. The favorite algo
feeds list could not be exercised on this account (it has no kind 10090, so
there is nothing to persist); it is wired identically and its type arguments
are compiler-checked, but it is not verified end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:23:38 -04:00
Vitor Pamplona
9ddf571b11 fix: persist the Concord community list so cold boot doesn't refetch it
AccountSettings has held backupConcordList and saved it on change since the
feature landed, but the field was never wired into LocalPreferences: it was
neither written to nor read back from the encrypted prefs. So it only ever
existed for the lifetime of the process, concordList() returned null on every
cold boot, and the kind-13302 joined-communities list had to be refetched from
relays before a single Concord plane could be subscribed.

Every sibling list — channel, community, hashtag, geohash, ephemeral chat,
relay group, trust provider — is persisted this way; Concord was the one that
was missed. That made it the only chat type whose rooms could not appear until
the network answered, which is the bulk of the cold-boot delay: the joined list
gates the control-plane REQ, the control plane gates the fold, and the fold
gates the channels.

Measured on device, boot -> first Concord plane wrap:
  - without the backup: liveCommunities sat empty for ~56 s waiting on the
    13302 fetch (first arrival from nostr.mom), first wrap at +45 s
  - with the backup restored: list decoded 1.6 s after boot (30 ms), first
    wrap at +6 s

Wired the same five sites the other lists use (pref key, save, read, parse,
restore). Prefs are encrypted and backupCashuWallet already sets the precedent
for persisting a secret-bearing event, so the community roots in the 13302
content are stored no differently than the wallet's.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:09:59 -04:00
Vitor Pamplona
1fee674350 fix: keep event-less placeholder rooms when a deletion arrives
The additive feed path re-filters the existing list whenever an incoming batch
contains a kind-5, dropping notes whose event has been deleted. Event-less
notes fell into the else branch and returned false, so they were dropped too.

An event-less row is a placeholder the filter synthesizes for a room with no
message yet — a just-joined Concord channel, NIP-29 group, Marmot group or
geohash cell. It carries no event, so it cannot have been deleted. Dropping it
removed every such row from Messages the moment ANY unrelated deletion landed,
and because this is the additive path the rows stayed gone until the next full
rebuild. A community whose channels are all quiet looked like it had never
loaded at all.

Verified on device: surviving Concord placeholders in sort() went 0 -> 14, and
a community that had been absent from Messages entirely now renders all of its
channels. Not Concord-specific — the same placeholderNote() pattern backs
NIP-29, Marmot and geohash rooms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:22:06 -04:00
Vitor Pamplona
50025660a3 perf: cut Concord revision churn and quadratic control-plane re-folds
Cold boot bumped the session revision ~292 times for 3 communities, driving 22
Messages rebuilds and re-deriving every plane subscription each time. Three
compounding causes, all measured on device:

1. Every refold republished state even when the fold was identical.
   ConcordCommunityState and its components were plain classes, so StateFlow
   conflation never applied and a prior-epoch wrap that didn't move the
   anti-rollback floor still counted as a change. Make the fold result compare
   by value (AuthorityResolver holds only immutable value fields; a data class
   with a private constructor is fine).

2. A control wrap bumped twice — once from ingest() returning STRUCTURAL and
   once from the per-session state watcher reacting to the same refold. Add
   ConcordIngestOutcome.STRUCTURAL_FOLD for the two control-plane branches so
   the manager leaves those to the watcher, which (given 1) now fires only on
   genuine change. Guestbook and base-rekey keep STRUCTURAL: they mutate
   members/the rekey buffer, not state, so no watcher covers them.

3. refold() and controlFloorsLocked() re-opened the WHOLE wrap buffer on every
   control wrap, and opening a wrap is a NIP-44 decrypt + parse — making a
   backfill quadratic in decryptions (~8.6k opens to ingest 93 wraps for one
   community). Memoize editions by wrap id: one open per wrap, ingest() stays
   synchronous and results are unchanged.

Measured over one cold boot: revision bumps 292 -> 87, Messages rebuilds
22 -> 7, and time from first fold to all 17 channels 43s -> 7.5s.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:21:54 -04:00
Vitor Pamplona
f67d242f7a fix: show Concord channels on Messages as soon as the control plane folds
A Concord control-plane fold is what first reveals a community's channels and
makes ConcordCommunitySession.state non-null, without which
ChatroomListKnownFeedFilter emits nothing at all for that community. None of it
flows through LocalCache.newEventBundles, so the additive feed path could not
see it: a folded channel only reached the Messages tab if a message for it
happened to arrive afterwards.

Cold boot therefore showed a subset of a community's channels, or omitted a
quiet community entirely, until some unrelated invalidation fired. Measured on
device: the Concord hub reported 3 communities / 17 channels folded in memory
while Messages rendered 3 rows and omitted one community completely.

AccountFeedContentStates already forces a rebuild for the Marmot, NIP-29,
geohash, view-mode and pin flows for exactly this reason; Concord was the one
missing collector. Add it, sampled the same way Account.kt samples this flow to
drive refreshConcordChannelIndex.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 13:21:35 -04:00
Claude
6033957b1c perf(relay): persistent-map FilterIndex snapshots, benchmark the sub population
FilterIndex registration runs on every REQ open/close but built each
new snapshot by copying both full maps — O(S) work and allocation per
REQ with S live subscriptions. Persistent (HAMT) maps keep the
wait-free single-load reads and CAS write loop while making a write
O(keys x log S) with structural sharing.

SmallReqFloorBenchmark grows a B@1k stage (1000 idle parked
subscriptions) to make the population cost visible, and its B stage
now enters queryRaw undispatched like production does: @1000 subs the
per-REQ cost drops 0.225 -> 0.151 ms and the measured population
penalty falls below run noise (was +0.011 ms per REQ).

With this and the undispatched replay, the in-process floor above the
raw store query is ~0.11 ms (was ~0.66 ms as first measured): A 0.120,
B 0.203, C 0.239 ms on a quiet machine.

Verified: FilterIndex tests, quartz relay.server suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:22:13 +00:00
Claude
37923d9101 perf(relay): run the stored REQ replay undispatched
SmallReqFloorBenchmark showed the per-REQ floor on small results is
dominated by pipeline, not the store (raw query 0.125 ms vs 0.785 ms
session REQ->EOSE in-process). Half of the dispatch slice was the
scheduler hop between handleReq's launch and the query coroutine:
starting the job with CoroutineStart.UNDISPATCHED runs the stored
replay and EOSE inline on the receiving coroutine (the reader-pool
acquire doesn't suspend when a connection is free), parking only at
the live tail. Measured: dispatch+frames slice 0.397 -> 0.207 ms.

Commands on a connection are processed sequentially, so nothing can
target the subscription before the job lands in the registry at the
first suspension point.

Verified: quartz relay.server suite, SmallReqFloorBenchmark, geode
full test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 16:14:12 +00:00
David Kaspar
a960ceb6a8 Merge pull request #3657 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 16:25:36 +01:00
davotoula
3fdb976aa9 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 15:24:41 +00:00
davotoula
a6b40cfc0f docs(skill): prevent duplicate-key mistake in find-missing-translations 2026-07-21 16:19:39 +01:00
davotoula
b616f69c1d update cz, se, pt, de 2026-07-21 16:19:31 +01:00
Claude
57ffb3386d feat(geode): enable the tag+kind+pubkey index, refresh measured docs
TagAuthorIndexBenchmark at 1M events settles the flag: the DM-room
shape (kinds + authors + #p, 65 client assembler call sites) drops
14.2 ms -> 0.66 ms (~21x, growing with corpus size) while batch-insert
cost stays inside run noise (49.0 vs 47.4 us/event). Existing relay
DBs build the index on next open via ensureOptionalIndexes.

Also refreshes the docs the numbers made stale: IndexingStrategy KDoc
now records the 200k and 1M measurements instead of a TODO,
MergeQueryExecutor's tag-merge note points at the new relayBench
reactions-watch scenario, FsQueryPlanner/FsDriverSelectionBenchmark
reflect the landed cost-based pick (149 ms -> 4.0 ms at 30k events),
and RELAY.md documents that strategy flag flips materialize indexes on
the next open.

Verified: quartz jvmTest store suites, geode test (126), desktopApp
LocalRelayStore tests (5, incl. reopening a default-strategy DB with
the new pubkey-alone flag), relayBench compiles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 15:07:12 +00:00
Claude
a3e239d33f feat(store): cost-based FS driver pick, runtime index materialization, new relayBench shapes
Acts on the measured gaps from TagAuthorIndexBenchmark and
FsDriverSelectionBenchmark:

- FsQueryPlanner: replace the fixed tags -> kinds -> authors driver
  order with a cost-based pick. Every legal driver (each tagsAll value,
  each tags key's value union, the kind set, the author set) opens a
  lazy directory iterator; all are drained in lockstep and the first to
  exhaust (the smallest listing) drives, so a giant idx/kind tree is
  never read past ~the smallest candidate's size. Fixes the 149 ms vs
  3.4 ms (~44x at 30k events) authors+kinds+limit regression.

- EventIndexesModule.ensureOptionalIndexes + SQLiteEventStore: flag-
  gated indexes are runtime config, not schema. An idempotent
  CREATE INDEX IF NOT EXISTS pass now runs on every open, so flipping
  an IndexingStrategy flag on an existing DB builds the index without
  a user_version bump.

- Desktop LocalRelayStore: enable indexEventsByPubkeyAlone. Shared
  ViewModels (Nip65RelayList, PrivateOutboxRelayList, VanishRequests)
  replay authors-only filters that full-scanned without
  (pubkey, created_at); existing DBs pick the index up on next open.

- relayBench Scenarios: add "conversation" (tag ∩ author ∩ kind, the
  DM-room shape, 65 client assembler call sites) and "reactions-watch"
  (kind 7 + #e IN 150 hottest notes) so the uncovered archetypes get
  head-to-head numbers vs strfry.

- quartz build: forward tagBenchScale/fsBenchScale to the test JVM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:54:48 +00:00
Vitor Pamplona
b6bf3b8a70 Merge pull request #3656 from davotoula/fix/bidi-control-literals
Replace raw bidi/invisible Unicode characters in source with \u escapes
2026-07-21 10:53:49 -04:00
Claude
41240eeab9 docs(store): record measured index/planner gaps as TODOs in both stores
Real numbers from the two new benchmarks, so the trade-offs are on the
decision points instead of in a chat log:

- IndexingStrategy.indexTagsWithKindAndPubkey: the KDoc called the
  kinds+authors+tags shape "rarely used", but the client assembler
  survey found 65 call sites. TagAuthorIndexBenchmark @ 200k events:
  DM-room query 9.4 ms -> 0.6 ms (~15x) with the flag on, insert cost
  +14% (41.5 -> 47.3 us/event). TODO: re-evaluate defaults (geode).

- MergeQueryExecutor: tag-path analogue of the follow-feed collect-all
  sort (kinds + #e IN [hundreds] + limit never merges). Measured
  12.8 ms cold / 6.0 ms since-bounded at 200k events; revisit if
  relayBench shows it at relay scale.

- FsQueryPlanner: fixed driver order sends authors+kinds+limit (the
  most common CLI shape) through the kind tree. FsDriverSelection-
  Benchmark @ 30k events: 149 ms -> 3.4 ms (~44x) driving from the
  author tree; TODO: cost-based pick by directory entry counts.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:39:21 +00:00
Claude
5988db010d test(store): add tag∩author and FS driver-selection benchmarks
The 2026-07 client filter-assembler survey mapped 551 Filter
constructions to ~12 query archetypes. Two hot shapes had no benchmark
coverage in prodbench or relayBench, and the FS store had none at all:

- TagAuthorIndexBenchmark: the DM-room shape (kinds + authors + #p,
  65 assembler call sites) with indexTagsWithKindAndPubkey off vs on,
  including the insert-cost delta of the extra index; plus the
  reactions watcher (kinds=[7], #e IN 300, limit) cold and
  since-bounded, which has no tag-side k-way merge today.

- FsDriverSelectionBenchmark: FsQueryPlanner's fixed driver order
  (tags → kinds → authors) on authors+kinds+limit — the most common
  CLI shape — comparing the current kind-tree driver against an
  author-tree driver with kind post-filter.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RzkoN3SJHCZWRAiadXXG4w
2026-07-21 14:34:57 +00:00
David Kaspar
73a58e042d Merge pull request #3655 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-21 15:34:00 +01:00
davotoula
e3dae26483 docs: add coding standard banning raw invisible/bidi characters in source 2026-07-21 15:04:57 +01:00
davotoula
86c8d0b12c fix: replace raw bidi/invisible control characters in source with \u escapes
the files are now pure-ASCII and visually unambiguous:

- BlossomPaymentSafetyTest: raw U+202E/U+202C test payload -> escapes
- BlossomPaymentRequired: BIDI_OVERRIDES char array -> escapes
- Sanitizer: RTL_OVERRIDES and ZERO_WIDTH regex classes -> escapes

Verified by mutation: with BIDI_OVERRIDES stripping disabled,
reasonBidiOverridesAreRemoved fails, proving the escaped payload still
carries a real U+202E. Emoji ZWJ sequences in RichTextParserTest are
intentionally untouched (functional joiners, not bidi controls).
2026-07-21 15:04:47 +01:00
vitorpamplona
db0ff71432 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-21 14:00:35 +00:00
Vitor Pamplona
72e27a3530 Merge pull request #3654 from davotoula/feat/share-note-as-qr
Share a note (link) as a QR code
2026-07-21 09:57:06 -04:00
davotoula
556ef9f880 fix(share-qr): stop the mode toggle labels clipping 2026-07-21 14:30:04 +01:00
davotoula
52c863a9c6 feat(share): add QR payload selection for notes
test(share): pin naddr encoding for addressable notes in QR payloads
feat(share): add strings for the QR share screen
feat(share): add fixed-height note card for the QR screen
feat(share): add display-only QR screen for notes
feat(share): register the ShareNoteAsQr destination
feat(share): add Share as QR to the note share sheet
fix(share): gate NSFW thumbnail with ContentWarningGate, prefer article title
fix(share): size the QR from available width, add a11y description
fix(share): add QR a11y strings, document QR row in ShareActionRows KDoc
fix(share): compact permanently-covered NSFW thumbnail, fix inert QR width cap
fix(share-qr): close sensitivity gate leaks and text/thumbnail bugs on the share-as-QR card
fix(share-qr): fix stale payload, unreachable controls, missing back button, and screen-wake handling
fix(share-qr): show image thumbnail for image-only notes, hide raw media URL
2026-07-21 14:29:49 +01:00
Vitor Pamplona
5e5ecdab8b Merge PR: NIP-34 git parity for amy + quartz interop fixes
Merges nostr proposal 2ca4f8ae into main:
- feat(cli): full NIP-34 git collaboration parity for `amy git`
- feat(cli): `amy git grasp list|set` (NIP-34 GRASP server list, kind 10317)
- feat(cli): `amy git browse|cat|log` — read git objects over smart-HTTP
- feat(cli): `amy git init` — bootstrap a repo from the local git checkout
- feat(cli): `amy git label` (NIP-32) and `amy git apply` (patch -> working tree)
- fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
- test(cli): live interop check against the real ngit-published amethyst repo
- fix(cli): shallow-clone euc, read truncation, process deadlock
- fix(cli): read routing, status perf, publish-ack + robustness
- fix: complete PR clone multi-value (kinds 1618/1620) + CLI robustness
- revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal

clone/web now serialize as single multi-value tags per NIP-34 (ngit drops
repeated ones) for kinds 30617, 1618 and 1620; readers stay tolerant of the
legacy repeated form.

The GitStatusIndex status-authority guard was reverted before merge: it
authorized against the status event's own `a` tag, which the author controls,
so a forged ["a","30617:<attacker>:x"] defeated it — and unverifiable statuses
were dropped permanently (no re-reduce when the 30617 announcement arrives),
rendering closed issues as open. Refiling as its own proposal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 23:54:49 -04:00
Claude
062ab0f012 revert(amethyst): drop GitStatusIndex auth change; defer to separate proposal
The isAuthoritative guard added to GitStatusIndex read the repository owner
from the status event's own `a` tag (GitStatusEvent.repositoryAddress() ->
first a tag, no kind filter, no cross-check against the target's repo). That
value is attacker-controlled: a forged kind:1632 carrying
`["a", "30617:<attacker-pubkey>:anything"]` makes `status.pubKey ==
repoAddress.pubKeyHex` pass, so the spoof it meant to block still succeeds.

It also regressed reads: reduceLatestByTarget only re-runs on a new
kind 1630-1633, so a status dropped while the 30617 was uncached stayed
dropped, leaving genuinely-closed items in the Open tab with wrong counts.

Keep this series focused on the quartz + cli NIP-34 parity work. The Android
status-authorization hardening (resolve the repo from the target item, load
the cached 30617, authorize against repo.pubKey + maintainers() + the target
author, and re-reduce when a 30617 arrives) will land as its own proposal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:38:20 +00:00
Claude
5f5356c0fe fix: complete PR clone multi-value, git-status auth spoofing, CLI robustness
Addresses review findings from a merge-time audit.

- **Complete the headline multi-value `clone` fix for PRs** (was applied only to
  kind:30617). GitPullRequestEvent (1618) and GitPullRequestUpdateEvent (1619)
  carry `clone` with the same spec shape but still emitted repeated single-value
  tags and read only the first value — so the exact interop bug this branch set
  out to kill was still live for PRs, both directions (ngit keeps only the last
  repeated tag; we lost every URL after the first from ngit's multi-value tag).
  Now both emit one multi-value `["clone", …]` tag and read both forms. Verified
  on the wire + GitNip34InteropTest + CLI harness (40 checks).

- **Android git-status spoofing (GitStatusIndex)**: newest-status-wins with no
  author check meant anyone could publish a kind-1632 and make someone else's
  issue render closed. Now filter statuses to the repository owner (from the
  status's own `a` tag), declared maintainers (from the cached announcement), or
  the target item's author — matching NIP-34 and the CLI's derivation. Pre-existing
  on main; this branch made the CLI/Android divergence visible.

- **CLI robustness**: `git comment`/`git patch` no longer block forever reading
  stdin on an interactive TTY (amy is non-interactive — error instead). The local
  `git` subprocesses in `git init`/`git apply` now drain stdout on a side thread
  under a bounded `waitFor` + `destroyForcibly`, so a wedged git can't hang the
  CLI.

Left as a follow-up (cosmetic): GitBrowseCommands.candidateUrls duplicates
GitRepositoryBrowserViewModel's — worth lifting to shared code, not worth the
cross-module coupling here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
4e0ac212ee fix(cli): second audit pass — read routing, status perf, publish-ack + robustness
Findings from a second review round (two independent reviewers), with fixes:

Read path (git issues/patches/prs/thread):
- **Reads ignored the repo's own relays** (correctness). They queried only the
  account outbox/bootstrap (general relays); NIP-34 events live on the repo
  announcement's advertised relays (often GRASP/git-specific), which general
  relays don't mirror — so `amy git issues <repo>` with no --relay could return
  empty. Now fetch the announcement once and read from queryTargets ∪ its
  advertised `relays`. Verified live: `git issues`/`git prs` on the amethyst
  repo now return real events (and derive `closed`) with NO --relay.
- **O(items × statuses) status rescan** with un-memoized `rootEventId()` reparse
  → pre-group statuses by root id once (O(1) lookup per item).
- **Status query could truncate / exceed relay caps**: statuses are now paged
  (`drainAllPages`) and the `#e` id set is chunked to 50 (under the common
  ~100-value relay filter cap).
- **Latency regression**: capped the list `drainAllPages` idle timeout to 12s
  (was the 30s default; `drain` had been 8s).
- **Nondeterministic status on same-second ties** → deterministic id tie-break.
- Reuse the fetched repo for the maintainer set (removes a redundant round-trip).

Write path:
- **`git init` silently reported success when the 30618 state publish failed**
  — its ack was dropped. Now surfaced as `state_published_to`/`state_rejected_by`
  with a stderr warning on total rejection.
- **`git apply`** feeds stdin as UTF-8 (was JVM default charset — corrupted
  non-ASCII patches) and joins the stdin thread in `finally` (no leak on error).
- **`normalizeCloneUrl`** drops the port from `ssh://git@host:port/…` (it was
  carried into the https URL, making it unreachable).
- **Delivery fallback** to the account outbox (repo unresolved / no advertised
  relays) now warns to stderr instead of reporting silent success.

Known limitation (documented, not fixed): patch-revision-chain status derivation
follows only the root item, and `git thread` shows first-level replies only
(nested trees and 1619 PR-updates are out of scope). 38/38 harness green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
63d01c9287 fix(cli): audit fixes — shallow-clone euc, read truncation, process deadlock
Findings from a review pass over the git-parity branch, with fixes:

- **`git init` announced a WRONG earliest-unique-commit on shallow clones**
  (interop-critical). `git rev-list --max-parents=0 HEAD` returns the shallow
  boundary commits, not the true root, so the repo would be announced under a
  different cross-fork identity than ngit computes. Now: detect shallow clones
  and omit the euc with a warning to pass `--earliest-commit`; on full clones
  derive the deterministic `--first-parent` mainline root instead of an
  arbitrary `tail -1`.

- **`git issues|patches|prs` silently truncated and mis-derived status** on
  active repos: one single-page `drain` pulled items AND status events under a
  shared cap, so status events (newer, more numerous) could crowd items out of
  the window and the close-status that determines an item's state could fall
  outside it → a closed item read as open. Now paginate the items
  (`drainAllPages`) and fetch exactly the statuses that `e`-reference them.
  Verified on the live amethyst repo: 51 PRs paginated, 19 correctly closed.

- **Pipe-buffer deadlocks** (latent): `GitInitCommand.git()` discards stderr to
  the OS (a chatty command can no longer fill its stderr pipe and hang the
  stdout read); `GitApplyCommand.runGit()` writes stdin on a background thread
  while draining stdout, so a patch larger than the pipe buffer can't deadlock.

- Minor: `git cat` binary detection uses an index loop instead of boxing 8000
  bytes; `GitRepositoryEvent.clones()/webs()` dedupe.

The harness `git init` test now runs against a fresh full checkout (this repo's
CI checkout is shallow) and adds a shallow-clone case asserting the euc is
omitted. 38/38.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
fa32ea50a3 test(cli): live interop check against the real ngit-published amethyst repo
Adds a `--live` block that reads the actual amethyst repository ngit publishes
to relay.ngit.dev and asserts our reader parses ngit's real multi-value `clone`
tag (currently 4 URLs) plus its published issues. This is the real-world proof
of the multi-value interop fix: the pre-fix reader would have surfaced only the
first clone URL. Opt-in (needs network + the live relay), skipped by default.

Verified manually end-to-end against the live repo: repo announcement (4 clone
URLs), issues (1621), patches (1617), pull requests (1618, with a real `closed`
status derived from ngit's status event), and a NIP-22 comment via `git thread`
all read correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
1a77c95531 fix(quartz): NIP-34 wire-format interop with ngit (clone/web, issue p, plain r)
Verified Amethyst's NIP-34 events byte-for-byte against the ngit reference
implementation (DanConwayDev/ngit-cli) and the spec, and fixed three real
interop divergences in quartz — so ngit/gitworkshop and Amethyst read each
other's git repos, issues, patches, and PRs without losing data.

- Repository announcement `clone`/`web` were emitted as REPEATED single-value
  tags (`["clone", a]`, `["clone", b]`). The spec and ngit use ONE multi-value
  tag (`["clone", a, b]`), and ngit's parser keeps only the LAST of repeated
  known tags — so multi-URL repos silently lost every URL but one in both
  directions. Now emitted as a single multi-value tag; `clones()`/`webs()` read
  BOTH the spec form and the legacy repeated form, so old events still parse.
  (`relays`/`maintainers` were already correct multi-value tags.)

- Issues (kind 1621) were missing the `["p", <repo-owner>]` tag that patches and
  PRs already include — a maintainer watching `#p` wouldn't see them. The
  builder now adds it (fixes both the CLI and the Android issue-creation path,
  which both passed an empty notify list).

- Patch / PR / PR-update `r` tags carried the `"euc"` marker
  (`["r", commit, "euc"]`). Per the spec and ngit that marker belongs only on
  the kind-30617 announcement; other `r` tags are plain `["r", commit]`. A `#r`
  filter matches either shape, so this is a spec-compliance/byte-parity fix.

`alt` (NIP-31) tags are intentionally still omitted — quartz treats the generic
alt client-hint as deprecated, and ngit/gitworkshop parse the structured tags,
so it isn't required for interop.

Adds `GitNip34InteropTest` (5 cases: multi-value write, tolerant read of both
forms, issue p-tag, plain patch r-tag) and 4 wire-format assertions to the CLI
git harness (37 offline). No regressions in the nip34 or Search suites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
d3e208322c feat(cli): add amy git label (NIP-32) and amy git apply (patch → working tree)
Close two more ngit/nak parity gaps:

- `git label TARGET LABEL[,LABEL]` — attach NIP-32 kind:1985 labels to an
  issue/patch/PR (the `ngit pr label` / `issue label` surface), over quartz's
  existing `LabelEvent`. Namespace defaults to `ugc`; `--namespace` overrides.
- `git apply PATCH_ID` — fetch a kind:1617 patch and apply it to the local
  working tree via `git am` (the `nak git patch apply` / `ngit pr apply`
  surface); `--check` dry-runs `git apply --check`, `--print` emits the patch.
  Shells out to `git` like `git init`, since it operates on the local checkout.

Verified end-to-end: a patch published to a relay, fetched, and `git am`'d as a
real commit into a scratch repo; labels land as kind 1985. The harness gains 5
assertions (label + a full publish→apply round-trip), now 33 offline.

Remaining out-of-scope items are documented: git-packfile push (needs a git
write layer quartz lacks) and NIP-34 cover notes (kind 1624, no quartz builder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
b06184ac49 feat(cli): add amy git init — bootstrap a repo from the local git checkout
Match `ngit init` / `nak git init`: read the local git repository and publish a
NIP-34 repository announcement, deriving the fields instead of making the user
type them. Shells out to `git` to determine the name (top-level dir), clone URL
(origin remote, ssh→https normalized), earliest-unique-commit (root commit),
and — for the accompanying kind:30618 state — the branch/tag tips and HEAD.
Publishes the 30617 announcement and (unless `--no-state`) the 30618 state in
one shot. Every derived value is overridable with a flag; outside a git repo
the derivation is skipped and `--name`/`--clone` are supplied manually.

This is the one `amy git` verb that shells out to `git`, since it is inherently
about the local working tree — exactly like the tools it mirrors.

Verified against the amethyst checkout itself (derives name=amethyst, the origin
clone URL, the root commit as EUC, and a 30618 with the live branches + HEAD).
The harness gains 4 assertions driving `git init` against its own checkout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:58 +00:00
Claude
45d33c016e feat(cli): add amy git browse|cat|log — read git objects over smart-HTTP
Give `amy git` the git-object read side of `nak git download` / a shallow
clone. `browse` lists a repo's tree, `cat` prints (or `--out` writes) a file at
a ref, and `log` shows recent commit history — all over the git smart-HTTP v2
protocol via quartz's `GitHttpClient` (the same shallow-clone path the Android
repo browser uses). REPO may be a NIP-34 coordinate/naddr (whose announcement
supplies the clone URL) or a raw http(s) clone URL; `--clone` and `--ref`
override the URL and branch/tag.

Read-only: pushing git objects back to clone/GRASP servers stays out of scope.

Verified live against a public repo (octocat/Hello-World) — browse/cat/log all
return correct trees, blobs, and history. The harness gains a `--live` block
(28 assertions with `--live`, 24 in the default offline run) exercising these
against `$LIVE_REPO`, skipped by default since it needs a reachable git host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
9922eef9f7 feat(cli): add amy git grasp list|set (NIP-34 GRASP server list, kind 10317)
Declare/read a user's preferred GRASP (Git-over-Nostr hosting) servers in
preference order — the NIP-65-style list `ngit`/`nak git` consult to decide
where PR tip branches (`refs/nostr/<pr-id>`) get pushed. `set` publishes a
kind:10317 to the outbox; `list` reads it back cache-first (anonymous-capable).
Thin assembly over quartz `UserGraspListEvent`. The git push itself stays out
of scope, as with the rest of the packfile transport.

Extends the git NIP-34 harness with a grasp round-trip (24 assertions) and
updates the README/ROADMAP/help tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Claude
ad66e6c224 feat(cli): full NIP-34 git collaboration parity for amy git
Extend `amy git` from repo announce/list/show/issue to the complete
pure-Nostr surface of `ngit` and `nak git`, so every NIP-34 collaboration
flow is scriptable without a GUI.

New sub-verbs (all thin assembly over quartz's `nip34Git` builders):

- `git state`   — kind:30618 repository state (branch/tag tips + HEAD)
- `git patch`   — kind:1617 patch from `git format-patch` (--file or stdin),
                  with --root/--root-revision, --commit, --parent-commit,
                  and --in-reply-to for revision chains
- `git pr` / `git pr-update` — kind:1618 pull request + kind:1619 tip update
- `git comment` — NIP-22 kind:1111 reply on an issue/patch/PR/repo (the
                  modern replacement for the deprecated kind:1622 git reply)
- `git open|applied|close|draft` — kind:1630/1631/1632/1633 status events
                  (aliases `merged`/`resolved` for applied); applied carries
                  --merge-commit / --commit / --patch
- `git issues|patches|prs` — list a repo's items with status derived from the
                  newest authoritative (owner/maintainer/author) status event,
                  with --open/--applied/--closed/--draft/--status filters
- `git thread`  — one item plus its status timeline and comments

Shared parsing/fetch/routing glue lives in `GitSupport`; the existing
announce/list/show/issue verbs now reuse it. The git *packfile* transport
(clone/fetch/push of real objects to clone/GRASP servers) stays out of
scope — it needs a git plumbing layer, not an event builder — and is
documented as such.

Adds `cli/tests/git/git-nip34-headless.sh` (21 assertions, drives the whole
flow against `amy serve` and checks the status-deriving reads) and updates
the README/ROADMAP command tables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UKMaNoK5M2PQKCAxhxWzPr
2026-07-21 03:37:57 +00:00
Vitor Pamplona
da451145af Merge pull request #3653 from vitorpamplona/claude/notifications-pagination-n0qahe
Add infinite-scroll backward pagination for notification history
2026-07-20 20:10:05 -04:00
Claude
a5ac06ad6c Merge branch 'main' into claude/notifications-pagination-n0qahe
Bring the notifications-pagination feature up to date with main.

Conflict resolution — the two live-notification managers:
main independently fixed the "notifications capped at a week" bug by a
different route: it dropped the oneWeekAgo() floor and now runs an all-time
`#p`+`limit` query gated by the lastNoteCreatedAtIfFilled() paging boundary
(kept together with its lastNoteCreatedAtWhenFullyLoaded collector job). That
updateFilter + newSub pair is one self-consistent unit, so this merge takes
main's complete version of AccountNotificationsEoseFrom{Inbox,Random}
RelaysManager and keeps the branch's dedicated `until`+`limit` history pager as
an additive layer on top (Account.notificationHistory, the history manager,
NotificationHistoryPaging.kt, the markers/retry UI, filter builders, tests).

Net: the feed gets main's all-time live query plus the branch's unbounded
backward pager. Note the two now overlap for users under the relay limit — the
pager's remaining unique value is scrolling past that limit; worth a review
pass, not a merge blocker.

Verified: :amethyst:compilePlayDebugKotlin, spotlessApply (clean), and the
notification unit tests (FilterNotificationsHistoryTest,
NotificationKindsContractTest) all green on the merged tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-21 00:00:28 +00:00
Claude
77908b2be0 refactor(notifications): extract history paging out of CardFeedView
CardFeedView had grown a ~90-line paging block plus an auto-retry loop,
constants and helpers that aren't about rendering cards. Move all of it into
a dedicated NotificationHistoryPaging.kt:

- rememberNotificationHistoryPaging(): the look-ahead buffer driver (with the
  per-burst cap), the stalled-relay auto-retry loop, cursor building, and the
  per-relay sentinels — returns the List<RelayReachCursor> the feed draws.
- BootstrapNotificationHistoryWhenEmpty(): the empty-feed hunt.
- The five tuning constants and the reachState / relayShortName helpers.

CardFeedView.FeedLoaded now just fetches the pager, calls the helper for the
cursors, and renders the detail dialog; the in-gap RelayReachMarkers stay
inline (they're per-row). No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:15:06 +00:00
Claude
05426d8c66 fix(notifications): bound eager fill + only the active feed drives paging
Address the two audit findings on the infinite-scroll driver.

- Bound the eager fill (#1): the buffer targets 100 rows but pages are pulled
  in events and notifications collapse heavily into cards, so on a dense
  account a fill could keep pulling until it downloaded the whole history to
  reach the row target. Cap consecutive pages pulled WITHOUT scrolling
  (NOTIFICATION_MAX_PAGES_PER_BURST); scrolling resets the budget, so paging
  resumes as the buffer is consumed. Appended older cards don't move
  firstVisibleItemIndex, so the from-top preload still fills the full
  look-ahead on open — only a dense whale is bounded.
- Only the active feed drives (#2): add drivesPaging (default true); the split
  screen passes page == pagerState.currentPage so an off-screen tab composed
  during a swipe no longer drives the shared account pager, and its buffer
  driver / auto-retry loop / sentinels stay idle. Single screen and side panel
  keep driving.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 21:07:30 +00:00
Claude
c8f43a1bd4 feat(notifications): auto-retry faulty relays + actionable relay detail
Improve the notifications history UX around slow/unreachable relays, keeping
the per-relay markers (they let users notice their own bad relays) but making
recovery automatic and the tap-through actionable.

- Auto-retry stalled relays with backoff (~3s→30s): once the buffer driver
  stops (every relay done-or-stalled) but some are merely stalled, keep
  re-advancing them so recovery no longer depends on the user scrolling to the
  marker or reopening the screen. One non-restarting effect so the backoff
  survives the transient in-flight blips each retry causes; cancels on leave.
- Add a "Try Again" action to RelayReachDetailDialog (shared): when a caller
  passes onRetry and a relay is stalled, the tapped marker's detail popup
  offers an active retry and drops the now-inaccurate "retries on reopen" hint.
  Notifications wire it to advanceAll; DM callers pass nothing (unchanged).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:36:43 +00:00
Claude
cc94ef9103 fix(notifications): retry stalled relays via per-relay sentinels
The buffer-only driver stopped paging once every relay was done-or-stalled
(exhausted), so a transient all-relays blip halted history until re-navigation
— bad for faulty relays with different datasets, exactly when we'd miss data.

Restore the per-relay RelayReachSentinels alongside the look-ahead buffer:
- the buffer driver (advanceAll) keeps the runway full from healthy relays;
- the sentinels retry an individual relay when its frontier marker scrolls
  into view — the recovery path once the buffer can't keep the frontier ahead
  (relays stalled/exhausted), naturally rate-limited by scrolling.

The buffer keeps the frontier ~a screen below the fold, so the sentinels stay
quiet during normal scrolling and only fire on stall/end. This also makes the
kept per-relay markers functional again (they drive the retry) instead of
purely decorative.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 20:14:30 +00:00
Claude
ad75344c3e feat(notifications): infinite-scroll paging via a look-ahead buffer
Switch the notifications feed from marker-visibility paging (load only when
the bottom marker is on screen) to infinite scroll: keep ~100 already-loaded
rows below the viewport so the user practically never reaches the end.

- Replace RelayReachSentinels with a buffer-depth driver: when fewer than
  NOTIFICATION_LOOKAHEAD_BUFFER (100) rows remain ahead of the last visible
  one, step every not-done relay one older page (advanceAll). It re-fires as
  each page settles until the buffer refills or all relays run dry — the
  wallet's lastVisibleIndex >= totalItems - N pattern with a large N.
- Keep the per-relay BackwardRelayPager engine, cursors and filters unchanged.
- Keep the in-feed per-relay progress markers + tap-through detail dialog;
  they are now purely visual (loading is driven by the buffer, not by them).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 19:48:27 +00:00
Vitor Pamplona
ef9a891974 Merge pull request #3652 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:17:43 -04:00
vitorpamplona
b2cfb5e7ee chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 19:14:54 +00:00
Vitor Pamplona
0601cfafbc Merge pull request #3651 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-20 15:12:11 -04:00
vitorpamplona
0214382d0f chore: sync Crowdin translations and seed translator npub placeholders 2026-07-20 18:19:06 +00:00
Vitor Pamplona
a0ab3ec66d Merge PR: feat(compose): resolve NIP-05 (incl. Namecoin .bit) in the @-mention popover
Merges nostr proposal b07eb505 into main:
- feat(nip05): add Nip05Id.parseLenient for mention/text rendering
- feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…

Also closes duplicate proposal 4b90b41f, which pointed at the same commits.

Beyond the feature, this replaces the unvalidated `Nip05Id("_", prefix)` raw
constructor in UserSuggestionState with `Nip05Id.parseLenient(prefix)`, closing
a hole where a typed mention such as `evil.com#x.bit` produced a GET to an
arbitrary host via `toUserUrl()`'s bare interpolation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 14:13:17 -04:00
Vitor Pamplona
15ce992b61 Merge pull request #3650 from vitorpamplona/fix/napplet-account-isolation-and-consent
fix: v1.13.0 pre-release QA — napplet account isolation, Concord authority, and 30 other fixes
2026-07-20 13:28:41 -04:00
Vitor Pamplona
8913af7a79 fix(concord)!: enforce CORD-04 rank gating on the Banlist fold
Closes the privilege escalation: any BAN holder could ban the authorities above
them — including the owner — because the Banlist gate checked only the BAN bit.
Once banned, a member loses all authority (`hasPermission` is `!isBanned && ..`)
and honest clients drop their events, so a single edition from the most junior
moderator permanently silenced every admin above them.

CORD-04 §3 requires the rank half: "One hard rule binds every action: the actor
must hold the required bit and strictly outrank its target — equal cannot act on
equal (an admin cannot ban a peer admin)", restated as §5 step 3. Only §4, which
defines the Banlist, states the bit half alone — which is why both this client
and Armada shipped the same rank-blind gate.

§3 is stated per TARGET while the Banlist is one whole-list document, so it is
enforced as a DELTA rule: an edition may only add or remove npubs its signer
strictly outranks, judged against the roster settled behind it; the owner is
never a valid target (position 0 is "supreme and unremovable"); and entries the
signer may not act on are IGNORED rather than rejecting the edition, so one bad
entry cannot discard the bulk-ban §4 recommends as the collision remedy, and a
rogue cannot grief the list by forcing rejections.

ConcordModeration.currentBanned now reads the honored banlist through the
resolver instead of decoding the raw head. Besides picking up the fork healing
it was missing, this closes a laundering path: our own next ban/unban would
otherwise re-publish an entry our fold refuses, under our signature.

BREAKING (consensus): Armada has not shipped this rule, so banlists can differ
between clients until it does — we now ignore a ban Armada honors whenever the
signer did not outrank the target. Shipping the spec-conformant behaviour was
judged better than continuing to honor an escalation. Write-up to send upstream
is docs/concord-banlist-rank-conformance.md.

The three tests added in 0ae6bc6698 as @Ignore-d documentation now pass and are
un-ignored; two companions (a moderator still bans a plain member, the owner
still bans anyone) passed throughout and pin what the fix had to preserve.
Full :quartz:jvmTest and :commons:jvmTest suites green.

Still open and documented, not addressed here: a banned BAN holder can lift
their own ban (a fixpoint-ordering question that needs a spec ruling), and a
forked ban survives an unban that does not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:53:11 -04:00
Vitor Pamplona
ea6762b137 docs(concord): Banlist rank gap is a conformance bug, not a spec gap
Correcting the previous commit's reasoning. It concluded from the two
implementations that CORD-04 does not rank-gate the Banlist and that enforcing
it would be a unilateral divergence. Reading the actual spec
(github.com/concord-protocol/concord, not in the Armada repo) shows the
opposite: §3 is normative and binds "every action" — "the actor must hold the
required bit and strictly outrank its target — equal cannot act on equal (an
admin cannot ban a peer admin)" — and §5 step 3 restates it. Banning is the
example the rule itself picks.

Only §4, the section defining the Banlist, states the bit half alone. Both
independent implementations read §4 in isolation and made the same mistake,
which is evidence about the section rather than about the readers.

So the fold fix is spec-mandated. It remains consensus-affecting (we would
ignore bans Armada honors until they ship), so it wants coordination rather
than a race, and the fold is still unchanged here.

Adds docs/concord-banlist-rank-conformance.md to share upstream: verbatim spec
citations, both implementations' gates, a delta-based rule that makes the
per-target requirement expressible against a whole-list entity, and two further
reproduced findings — a banned BAN-holder can lift their own ban (so bans do
not stick against any BAN holder), and a forked ban survives an unban that does
not chain onto it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 12:05:21 -04:00
Vitor Pamplona
0ae6bc6698 fix(concord): rank-gate the Ban and Remove affordances
Ban/Remove were offered to any BAN holder against any non-owner, ignoring rank
— unlike the role picker, which routes through `canActOn`. Both the Members
roster and the message-level path (`Account.concordBanTarget`, the chokepoint
for the quick-action menu, the note dropdown, the note action sections and the
chat action sheet) now require `canActOn(me, target, BAN)`.

This is NOT the no-op it first looked like. The premise that the fold would
drop such a ban is wrong, and a test proves it: BANLIST is a single whole-list
entity, so `authorizedHeads`/`banGate` gate on the author's BAN bit alone and
never rank-check the list's *contents*. A rank-5 moderator's ban of a rank-1
admin is therefore ACCEPTED by every client, and the admin then loses every
permission, since `hasPermission` is `!isBanned && ..`. It is privilege
escalation, not a silent no-op.

The fold is deliberately left alone. Armada has the identical gap — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while
its role path uses the rank-aware `canActOnPosition` — so rank-gating our fold
would make us ignore bans every other client honors, splitting the banlist
across clients. Closing it needs a spec change, like CORD-05. Refusing to
AUTHOR such a ban restricts only what we write, never what we accept, so it
cannot diverge consensus.

Three `@Ignore`-d tests in AuthorityResolverTest state the fold-level invariant
and currently fail by design; two companions assert the gate does not
over-correct (a moderator still bans a plain member; the owner still bans
anyone). Un-ignore the first three when the spec closes the gap.

The owner short-circuits the check rather than going through `canActOn`, which
begins at `hasPermission` and is false while banned — since a rogue BAN holder
*can* currently banlist the owner, routing them through it would let them be
locked out of moderating their own community.

Device-verified on Amethyst QA Concord as Dr. Edo (QA Lead, rank 2): Bob
(Admin, rank 1) now offers only the disabled "Roles… / You don't outrank this
member" where Ban and Remove used to be enabled, while the Helper (rank 5)
still offers Roles…, Ban and Remove.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:54:28 -04:00
Claude
ba8de9ba21 feat(notifications): paginate history by time with in-feed load markers
Notifications were pinned to the recent week with no way to scroll further
back. Mirror the NIP-04 / gift-wrap DM approach: a backward, per-relay
until+limit pager driven by in-feed window-limit markers that pull the next
older page only while visible.

- Add Account.notificationHistory (RelayLoadingCursors) and
  AccountNotificationsHistoryEoseManager, a BackwardRelayPager over the
  inbox + NIP-29 group-host relays, registered always-on in
  AccountFilterAssembler. It parks until a marker advances a relay.
- Add filterNotificationsHistoryToPubkey / filterGroupNotificationsHistoryToPubkey
  and AllNotificationKinds: one combined-kinds filter per relay so the single
  per-relay cursor stays gap-proof (empty page + EOSE = nothing older).
- Wire RelayReachMarkers + RelayReachSentinels into the notifications card
  feed (CardFeedView), with an empty-feed bootstrap, so scrolling a relay's
  marker into view loads more.
- Fix the two live notification loaders to a fixed one-week tail (drop the
  fullness-driven `since` drift), letting the marker-driven pager cleanly own
  everything older — the same live-tail/history split DMs use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QZ7uGCKZZWzXXpHyVmXw8f
2026-07-20 15:53:32 +00:00
Vitor Pamplona
c15774e4e1 docs(qa): record the notifications deadlock and close two open findings
Adds the Notifications and Concord role-grant rows to the coverage table,
retires the "grantConcordRole is unreachable" finding, and records two new
ones: the Members roster offering Ban/Remove on members the viewer doesn't
outrank (silently dropped on fold), and notification cards whose target note
isn't cached rendering as placeholders.

Also adds the pattern worth carrying forward: a paging boundary gated on a
full page deadlocks against a narrow query window, and the empty state is
self-sustaining.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:36:39 -04:00
Vitor Pamplona
4e7242a295 feat(concord): assign CORD-04 roles from the Members roster
`Account.grantConcordRole` had been implemented with zero callers, so role
grants were unreachable from the app while the changelog claimed they ship.
This adds the missing surface: a "Roles…" item beside "Make admin" opening a
multi-select over the roles the viewer may hand out.

Both rank rules are enforced by delegating to `AuthorityResolver` rather than
reimplementing them:

- assignable roles are `roles().filter { myRank < it.position }` — the fold
  drops a grant whose granter does not strictly outrank every assigned role, so
  offering one at or above our own position would publish an edition that every
  client then silently discards;
- reachable members are `authority.canActOn(me, target, MANAGE_ROLES)`, which
  already folds the whole rule (hold the bit, not banned, target isn't the
  owner, strictly outrank) and makes self-promotion fall out for free.

Out-of-reach members show the item disabled *with a reason* instead of omitting
it, so there is no silently no-op control.

The grant REPLACES a member's role set rather than merging into it, so the
dialog preselects their current roles. That preselection is provably complete:
a member's rank is the lowest position they hold, and the dialog only opens
when we strictly outrank that rank, so every role they hold sits strictly below
us and is therefore rendered — no held role can be silently stripped.

`amy concord roles` also gained a `grants:` section reading the post-fixpoint
`authority.roleHolders()`. It previously printed role *definitions* but never
the *grants*, which made the fold outcome unverifiable from the CLI; a
rank-violating grant now shows up as visibly absent rather than as if it landed.

Device-verified on a test community (Admin/QA Lead/Helper/Greeter): the picker
hides roles above the viewer, disables on members who outrank them, preselects
correctly, and a saved grant survived the fold and a fresh relay drain read
back from a second client.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:35:48 -04:00
Vitor Pamplona
97b861dd5e fix(notifications): remove the 7-day since floor that emptied the tab
The notifications query pinned `since` to `oneWeekAgo()` whenever it had no
EOSE timestamp. Combined with two other facts that produced a deadlock:

- the EOSE `since` map is in-memory only (`SincePerRelayMap = MutableMap<..>`),
  so EVERY cold start re-pinned the window to 7 days; and
- the backward-paging escape hatch (`lastNoteCreatedAtIfFilled()`) only arms
  once the feed holds a FULL page (`notes.size >= localFilter.limit()`).

The feed could not fill a page because the query only asked for a week, and the
query could not widen because the feed never filled. Any account whose last
inbound mention was older than 7 days saw a near-empty Notifications tab
forever — including a fresh install of a long-established account.

Worse, the floor was applied to `filterSummaryNotificationsToPubkey` (kinds
1/7/6/9735 — the overwhelming bulk of notifications), which was not even wired
to the paging fallback that the secondary per-key kinds received.

Home is the precedent and does not do this: `filterHomePostsByAllFollows`
passes `since ?: boundary`, i.e. plain null on a cold start, relying on the
relay-side `limit` to bound the response. Notifications was the outlier. These
filters are `#p`-scoped to the user's own key and carry a `limit`
(2000/500/200/20), so an all-time query is one index scan returning at most
`limit` events newest-first — same cost, strictly more useful.

Measured on the test account before the fix: 163 events p-tagging it exist on
relays and are fetchable (100 kind-1 and 100 kind-7, both hitting the query
cap), 77 of which clear the follow gate — yet the tab rendered ~3, because the
account's most recent inbound activity was a month old. After the fix the tab
scrolls back through Feb 2025.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:34:07 -04:00
Vitor Pamplona
4ee0416a90 docs(qa): record v1.13.0 test coverage, open findings and recipes
The 30 fixes from this session each carry their reasoning in their own
commit message, and the regression tests are in the repo. What none of
that captures is the shape of the testing itself: what was actually
exercised, what was not, what we knowingly left broken, and how to
reproduce the setups. That lived only in one conversation.

Records four things:

- **Coverage**, honestly split three ways: exercised on device; fixed but
  only unit-verified (roughly half the session's work, including the
  entire Concord authority chain); and never opened at all (Nests,
  Marmot, the whole Desktop app, payments, notifications). Plus the
  platform gaps — one tablet, one flavour, one ABI, Android 14 only while
  targetSdk is 37, and no real `release` build.

- **Open findings** we chose not to fix, so they are decisions rather
  than oversights: the unbumped versionCode, the Firebase scheduling
  failure that may cost Crashlytics, Tor settings needing a restart, an
  unreachable relay reported as empty, WebView profiles surviving logout,
  control-plane edits still dropping unknown keys, and the CORD-05
  root-binding gap that Armada shares and therefore needs a spec
  conversation.

- **Setup recipes** that took real time to work out: isolating `amy` by
  `$HOME`, which relays actually accept a NIP-29 create, deep-linking
  past a 1000-entry directory, and proving "no network before consent" by
  running a local relay over `adb reverse` and counting events either
  side of the tap.

- **Patterns** that recurred often enough to be process problems: tests
  that assert the bug, implemented-but-unreachable capabilities, Gradle
  serving a stale up-to-date test run and reporting success for a
  deliberately broken build, and four confident diagnoses that
  measurement overturned.

Written for whoever picks this up next, including the parts that reflect
badly on the testing rather than only the parts that reflect well.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:56:21 -04:00
Vitor Pamplona
a040aa522c fix(perf): keep favicon and settings disk reads off the main thread
Two StrictMode disk violations on startup and on IPC, fixed differently
because they are different problems.

**Favicon storage was writing on the IPC handler thread**, i.e. the main
looper: `BrowserIconRegistry.record` did `File.writeBytes` inline, and
`init` scanned the directory with `listFiles`. Both now run on an IO
scope. The directory is still published synchronously so `iconModelFor`
and `record` work immediately — only the scan and the write are deferred.
`keys` updates after the bytes are actually on disk, so a reader is never
told an icon exists before the file backing it does, and until the scan
lands an icon renders its placeholder for a frame and then recomposes,
which is what the StateFlow is for.

**The settings read was left synchronous on purpose, and stays that
way.** `notificationServiceEnabled` reads SharedPreferences inside a lazy
initialiser; the surrounding comments record why it is not hydrated
asynchronously — an async hydrate reopens a window where a late disk read
clobbers a user's toggle. Converting it would have looked like a
StrictMode cleanup while resurrecting a settings-corruption bug. Instead
the prefs file is warmed on an IO thread at startup, so the first
synchronous read hits SharedPreferences' in-memory cache. Best-effort by
design: if a main-thread reader wins the race it pays the disk hit once,
exactly as before, and correctness is unchanged either way.

Verified on device: `notificationServiceEnabled` StrictMode hits went
from 9 to 0 on a cold start, no icon storage failures, 884 ms cold start,
no crashes — and both pinned web-app icons (ditto.pub and Brainstorm)
still render, which is the thing making the write async could plausibly
have broken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:52:05 -04:00
Vitor Pamplona
4255ba3345 fix(browser): capture icons from sites that declare only an SVG favicon
A pinned web app showed a generic placeholder instead of its icon —
ditto.pub failed while brainstorm.nosfabrica.com worked. The
discriminator is what each site declares:

    brainstorm:  favicon.svg  AND  favicon.png   <- the PNG is why it works
    ditto:       logo.svg only  (+ an apple-touch-icon nobody read)

Icons were captured solely through `WebChromeClient.onReceivedIcon`,
which hands back a rasterized Bitmap. Android WebView does not decode SVG
favicons into that callback, so a site offering a raster alternative gets
picked up and an SVG-only site never fires it at all: nothing is
recorded, `iconModelFor()` returns null forever, and it fails silently —
no error, no log line, just a permanently generic icon. SVG-only
declarations are increasingly the norm, so this was set to affect more
apps over time.

Adds a sniffer that, after a main-frame load, ranks the page's declared
icons (raster `rel="icon"` → SVG `rel="icon"` → apple-touch-icon →
/favicon.ico) and fetches the best one IN PAGE CONTEXT, falling through
on failure. The bytes are size-bounded and magic-byte validated before
being relayed down the existing record path; anything unrecognised is
dropped.

Fetching in page context is the point, not an implementation detail. The
registry captures from the WebView deliberately — "an alternative to the
main app fetching host/favicon.ico itself, which would bypass Tor and
leak the request". Ditto's /favicon.ico exists and returns a valid icon,
so fetching it from the app would have been the easy fix and the wrong
one. Every byte still comes through the sandbox WebView's own network
path.

No rasterisation needed: coil3's SVG decoder is registered on the
singleton loader and sniffs by content rather than extension, so stored
SVG bytes decode even under the registry's .png filename. Only a leading
BOM/whitespace trim was required so byte 0 is the '<' the sniffer wants.

Also fixes a second gap found while diagnosing: `NappletBrowserService` —
the EMBEDDED path backing pinned bottom-nav tabs — had no icon capture at
all, so a pinned app's icon depended on having once opened it full
screen. It now gets both the missing raster callback and the sniff.
`NappletHostService` deliberately left alone: it serves verified blobs
over a synthetic internal host, and applet icons already come from the
manifest, so capture there would be dead code.

The sniff runs after a delay and skips when the raster callback already
claimed the host, so sites that worked before are untouched.

Verified on device: ditto.pub now shows its real icon on the favorite
card, the pinned sidebar tab and the recents row, via both the embedded
and full-screen paths; Brainstorm is unchanged; example.com (no usable
icon) degrades to the placeholder with zero record calls and no hang; no
crashes.

Pre-existing, not fixed: `BrowserIconRegistry.record` writes the file on
the IPC handler thread, tripping StrictMode. It did so before this change
— which now simply gives it more occasions. Moving that write off-main is
a cheap follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:09:24 -04:00
Vitor Pamplona
08bd5d9b4a fix(napplet): tightening an app's trust level drops its live grants
Changing an app to PARANOID did not stop it signing. `sessionAllows` is
consulted BEFORE `signerLedger.decide()`, so a live "allow for this
session" grant short-circuits exactly the check the stricter policy would
have failed. The user picks "I'm a bit paranoid", the UI updates, and the
app carries on signing on the strength of a grant made under the old
policy — until every applet surface closes.

The trust level is a decision about how an app is treated from now on, so
the policy change now drops what that app is currently holding, the same
way revoking and forgetting already do. It fires on any change rather
than only on tightening: loosening is the user's call too, and a stale
grant surviving a deliberate re-decision is surprising in either
direction.

Completes the revocation work — the three paths that change what an app
may do (forget, per-op revoke, trust level) now all clear its live
session grants.

Not automatically tested: this is a Compose click handler and `amethyst`
has no Robolectric. The underlying `revokeSessionGrants` is covered by
`revokingAnAppDropsItsLiveSessionSignerGrants`, which was verified to
fail before its namespacing fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:28:34 -04:00
Vitor Pamplona
b0668baeec fix(napplet): make revoking an app actually drop its live grants
Three defects, each of which made revocation look like it worked.

**`revokeSessionGrants` had no callers.** It was added with the KDoc "so
revoking an app takes effect immediately instead of lingering until this
broker instance dies" and then never wired, so revoking an app in
Connected Apps left its in-memory session grants active. The user
revokes; the app keeps signing.

**And it was broken as written.** `sessionAllows` keys are the
account-namespaced `napplet:<signer>:<coordinate>|<op>`, but every revoke
call site holds the BARE coordinate, so the prefix match found nothing.
Wiring it naively would have looked correct and silently done nothing. It
now namespaces before matching, and also clears the post-Cancel re-prompt
cooldown so a revoked app prompts on next use instead of being quietly
dropped.

**Worse: there were three ledgers.** `NappletBrokerService`,
`ConnectedAppsScreen` and `ConnectedAppDetailScreen` each constructed
their own `NappletPermissionLedger`, while ALLOW_SESSION grants are
per-instance in-memory state. So "Forget" cleared the screen's own
always-empty session map while the grants the broker actually consults
lived on. The KDoc described a process-wide singleton; it wasn't one.
Promoted to a real singleton in AppModules alongside the existing
permission store, and shared by all three.

The screens are plain composables with no binder to the broker service,
so rather than invent an IPC path the cached broker moved to the
service's companion under a lock — matching the sibling main-process
registries in that package. Both revoke paths call it: the Forget button
and the per-op revoke.

Also gives `NappletPermissionLedger.endSession()` its first caller, which
promoting the ledger made necessary: it used to die with the service, so
session grants had a natural bound. Now that it outlives the service,
`onDestroy` restores exactly the lifetime ALLOW_SESSION already implied.
The boundary is safe — the service is bind-only and is destroyed only
once every applet and browser surface has unbound, so switching between
two open applets never drops grants mid-use. Deliberately NOT wired to
account switch (already handled by account-keying) or to backgrounding
(would re-prompt mid-use).

Test notes, kept honest: the revoke test was verified to fail before the
namespacing fix. The `endSession` test PASSES without the change —
`endSession` itself was always correct, the bug was that nobody called
it — so it is characterization for the new lifetime contract, not a
regression test. The `onDestroy` wiring and the composable click handlers
have no automated coverage; `amethyst` has no Robolectric and no harness
was invented for them.

Known gap, left alone deliberately: changing an app's trust level to
PARANOID does not drop its live session grants, because `sessionAllows`
is consulted before the signer ledger. That is a revoke-shaped action and
belongs in the same fix, but it is a behaviour change and was out of
scope tonight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:13:37 -04:00
Vitor Pamplona
62440748a7 fix(concord): stop a rejected edition orphaning the honest ones after it
An unauthorized control edition in the middle of an entity's chain
permanently froze that entity. Observed on device for a member's GRANT:

    v0 owner    (grant mods)
    v1 owner    (grant admins)     <- fold stopped here, forever
    v2 MIDTIER  (escalation, correctly rejected)
    v3,v4,v5 owner                    orphaned, unreachable

`AuthorityResolver` filtered unauthorized editions out BEFORE calling
`EditionFold.foldEntity`, and the walk only advances when the next
version cites the current head's hash. Removing v2 severed the chain, so
every honest edition above it was lost. Any member could permanently
freeze any member's role assignment — including the owner's ability to
change it — with a single event, recoverable only by a Refounding. It
predates the recent rank gates (verified with a zero-role identity); the
gates only widen which editions can poison.

Armada does not have this bug, and its approach settles the design.
Reading its control-plane fold (read for semantics only — Armada is
AGPLv3, Amethyst is MIT, no code taken): the chain walk runs over the
UNFILTERED set, producing an ordered candidate list — chain-verified head
first, then every remaining edition version-descending — and authority is
applied AFTERWARDS, per candidate, picking the first admissible one. A
rejected edition is skipped during the ascending admissibility walk
without truncating it. For the chain above, Armada picks v5.

So the fix is not to filter later but to gate later: `EditionFold` gains
candidate-based gated folding, and the resolver and community state now
gate per candidate instead of pre-filtering the pool. Authority checks
themselves are unchanged — only WHEN they run moved. Applied to ROLE,
GRANT, BANLIST, CHANNEL, METADATA and the authorized-head map.

The writer had to be fixed too, for a sharper reason than expected. With
an ungated `headOf`, a rogue banlist edition at the tip is read as
current state, so the owner's next ban REPUBLISHES THE ROGUE'S CONTENT
UNDER THE OWNER'S SIGNATURE — an unauthorized empty banlist laundered
into an owner-signed one the moment the owner bans anyone else. Tolerant
reading cannot heal that, because the resulting edition is genuinely
authorized. `ConcordModeration.headOf` now folds the authority-gated
heads, and `owner` is a REQUIRED parameter rather than defaulted, since a
silently-wrong default here is a consensus footgun.

Banlist healing is preserved with one necessary change: the ancestry walk
now runs over the full pool rather than the authorized subset. Ancestry is
structural — walking only authorized editions stops at the rejected one
and misreads genuine ancestors as concurrent forks, resurrecting bans an
unban had cleared.

Six regression tests, each verified to fail without the fix. Two process
notes worth recording: the first "without the fix" run reported BUILD
SUCCESSFUL because Gradle served a stale up-to-date `jvmTest` — trusting
it would have meant concluding the tests were worthless. And the
forged-edition test initially passed both ways because the forgery's
content coincided with the honest outcome; it was rewritten so the
mid-chain arm genuinely discriminates.

The rank-gate, rogue-higher-version, floor and rollback tests all pass
unchanged.

Known gap: `headOf` gates through the per-kind permission map, which is
coarser than the resolver's rank gates, so the writer can still pick a
head the reader rejects when an in-permission but out-of-rank edition
sits at the tip. Tolerant reading makes that benign, but it is not an
exact reader/writer match; tightening it needs the resolver to expose
per-entity heads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 09:06:13 -04:00
davotoula
2d29f7ccda no empty methods
use constants instead of repeating strings
2026-07-20 13:58:06 +01:00
Vitor Pamplona
5f712b3a8a feat(concord): let a member leave a community
`Account.leaveConcordCommunity` has existed since the feature landed and
had ZERO callers anywhere in the repo — so joining a Concord community
was one-way. It stayed in the account's kind-13302 list and in Messages
permanently, with no affordance on the community screen, the Members
screen, or Messages.

Found because a test account joined a probe community whose only relay
then went away: stuck in the list, channels unrecoverable, nothing to
tap. Third capability found this session that is fully implemented and
unreachable, after `ConcordInviteBundle.isExpired` (called only from a
test, so invite expiry was decorative) and
`NappletPermissionLedger.endSession` (never called, so session grants
outlived every revoke). Each made a feature look complete to anyone
reading the model.

Adds "Leave community" to the community screen's top-bar overflow,
mirroring how NIP-29 relay groups already place membership-destroying
actions, behind a confirmation. It renders whether or not the Control
Plane ever folded, which is the case that matters — a dead-relay
community never folds.

The copy is deliberately narrow about what leaving does: it removes the
community from THIS account's list and stops syncing, it does NOT notify
the community or remove anyone from a roster, and returning needs a new
invite.

Owner leaving is allowed, with an extra warning. Blocking it would make
the actual stuck case unfixable, since the motivating community was one
the account created; and it is the user's own private list to edit.
But it is irreversible in a way worth stating: `ownerSalt` lives only in
that entry, so discarding it retires the community rather than
transferring it. Ownership is read from the stored entry rather than the
folded authority, because a dead-relay community has no folded authority.

Works offline by construction: the underlying call rewrites the local
list (falling back to the on-disk backup when nothing folded) and
publishes fire-and-forget to the user's OWN outbox — never the
community's relays — so the UI does not wait on a relay that cannot
answer. Both paths that could resurrect a left entry were checked:
stranded recovery iterates live communities only, and the list import
takes the newest 13302, which is ours.

Tests cover the real logic behind the button — `unfollow` was previously
untested — driven through the offline-backup path with no cached relay
event: drops only the named community, preserves other memberships'
secrets, empties cleanly on the last one, no publish for a community
never joined, and the rewritten list stays self-encrypted.

Not verified on device: building an APK would have replaced the build a
concurrent Concord authority test was running against. The composable
itself has no automated coverage — `amethyst` has no Robolectric.

Two follow-ups noted, not fixed: leaving does not unpin a community from
the bottom bar, so a pinned one leaves a dead tab; and
`grantConcordRole` is another zero-caller capability — the general
CORD-04 role-grant path is unreachable, with only the narrower
make/remove-admin wired up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:57:52 -04:00
Vitor Pamplona
ab2f4d2da8 fix(nip29): split group-state REQs so relays stop rejecting them
A joined NIP-29 group showed its raw hex id instead of its name — in the
header, the Messages list and the browse directory — offered "Join" to an
account the relay already listed as an ADMIN, and hid the entire admin
surface. Members, Edit group, Invite people, the Threads "+" FAB,
pin/unpin, share and the member count all sit behind the same
`isMember()` gate, so none of them were reachable. Posting was blocked
too.

The cause is not membership modelling, warmup skipping, or the local
kind-10009 list. **The relay rejected the subscription outright.** The app
asked for all five group-state kinds in one filter, captured on the wire:

    SEND ["REQ",…,{"kinds":[39000,39001,39002,39003,39005],"#d":[…]}]
    RECV ["CLOSED",…,"blocked: it's not allowed to mix metadata kinds
                      with others"]

The whole REQ is dropped, so zero 39000/39001/39002 reach LocalCache.
Membership is derived from those events, so it fell back to NONE and
every gated control disappeared. Chat messages arrived on a separate `#h`
REQ, which is why the group looked half-alive rather than broken.

relay29/khatru29 evaluates that rule PER FILTER, confirmed by probe:

    39000-39003 + 39005 in one filter   -> CLOSED, 0 events
    the same kinds as two filters       -> 4 events, EOSE
    39000-39003 alone                   -> 4 events

So the fix is to split the kinds into two filters in the same REQ — no
extra subscription, no new assembler. This repairs every joined group on
any relay29-family relay, not just the one that surfaced it: the always-on
joined-state subscription was hitting the identical rejection everywhere.

Two hypotheses were tested and disproved rather than assumed. The
join-tap/warmup theory was wrong — instrumentation showed the filter was
built correctly with the right scope and only failed on the wire. The
`since`-on-replaceable theory was also wrong here: `since` logged null on
every call, since the map is per-subscription and reset on disconnect
rather than a persisted floor. Both left unchanged.

Regression tests fail on the old single-filter behaviour and pass on the
new one, verified by reverting the behaviour while keeping the constants
so the tests genuinely run rather than fail to compile.

Verified on device: header now reads "Amethyst QA 1.13" with an Admin
badge and member count 2, composer enabled, overflow menu showing
Members / Edit group / Invite people / Leave — all consistent with the
relay's roster.

Not fixed here: a stale "Requested" state is client-side only and is
never reconciled against an arriving roster. With this fix a genuine
pending→member transition now resolves, but a rejected 9021 still shows
"Requested" until restart. Separate change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 00:18:13 -04:00
Vitor Pamplona
a87049e451 fix(chats): stop crashing when a relay's group directory is sorted
Browsing a relay's groups (Relay Groups → Find groups → pick a relay)
crashed the app to the launcher:

    java.lang.IllegalArgumentException: Comparison method violates its
    general contract!
      at java.util.TimSort.mergeLo
      ...
      at RelayGroupChannelListScreen$allChannels$3$1$1.emit

`sortedBy { it.toBestDisplayName().lowercase() }` re-evaluates its key on
every comparison, and the key comes from mutable shared state — the
channel's display name, which a kind-39000 directory event can change
while the sort is running. TimSort detects the inconsistency and throws.

The failure scales with directory size and needs no user action: a relay
hosting 1237 groups tripped it on the first browse, while a relay with 18
never did — which is why an earlier sweep of this same screen missed it.
v1.12.6 already fixed this class elsewhere ("snapshot live-stream status
order before sorting"); these sites were not covered.

Adds `sortedBySnapshot`, which computes each key ONCE before any
comparison runs, and applies it to all four sites that sorted live
objects by their display name: the relay group directory (both the
initial value and the observer), the parent-group picker, and the
name-ordered search results. The picker's site also had to materialise
its Sequence first, since sorting lazily would have re-introduced the
same window.

Verified on device: the exact tap that crashed now loads the screen with
zero FATAL EXCEPTIONs and the app stays in the foreground.

Found while setting up a NIP-29 group to test the admin surface, which is
also how the directory got large enough to expose it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:43:40 -04:00
Vitor Pamplona
894dc7d99b fix(location): don't self-select a location the user never picked
The map picker opened blank, showed "Tessalit, ML" — the middle of the
Sahara — as if it were chosen, and had Confirm/Teleport ENABLED, so a user
could join a geohash cell they never selected. Three of those are one bug
and the fourth follows from it. Notably there is no tile, network or Tor
problem: tiles were downloading and rendering the whole time.

**The defect.** `onCenterChanged` treated any map movement beyond
`SELECT_MOVE_EPS` (0.0005°) as the user's first pick. osmdroid emits a
scroll when the MapView is first laid out, reporting a pixel-quantized
centre — and at the opening zoom one pixel is about 0.4°, roughly 800x the
epsilon. The phantom scroll therefore always cleared the threshold and the
picker selected a location before the user touched anything. No epsilon
can fix that: the quantization error is unbounded at low zoom. Replaced
with a `mapTouched` gate, so only finger-driven movement can become a
selection.

**Why it looked blank.** That phantom selection made the level effect zoom
to 12 over empty desert, and the night tile filter darkened featureless
Sahara tiles to near-black. Tiles were present; there was nothing to see.
Tapping "Region" re-zoomed and a detailed map appeared instantly, which is
what ruled out a loading failure.

**Why Tessalit.** The self-selection encoded the default centre, and
`WORLD_CENTER_LAT/LON = 20.0/0.0` is inland Mali — not "mid-Atlantic" as
the comment claimed. Moved to -30.0 longitude, genuinely unnamed ocean and
off the prime meridian; two nearby hardcoded 20.0/0.0 literals now use the
constants.

**The label mismatch was mostly a misread, with a real bug behind it.**
20N/0E sits exactly on the prime meridian, so a sub-kilometre pan flips the
geohash between the `e…` and `s…` halves of the world — both cells really
were Tessalit, so the name was right. The genuine bug is narrower: the name
came from a 450 ms-debounced cell, so mid-pan the previous cell's name
rendered beside the new geohash. The name now renders only when the
debounced cell matches the live one, and an unresolvable cell shows no
label rather than echoing its geohash.

Side benefit: the seeded composer path no longer has its exact seed
coordinates overwritten by osmdroid's quantized initial scroll.

Verified on device before and after, on both the Teleport screen and the
composer dialog: tiles on open, no bogus place, no selection until a real
touch, Confirm disabled until then; then pan → cell with no label at sea,
pan → "Oriximiná, BR", search → "Lisboa, PT", composer pick → confirmed.

Pre-existing, not fixed: in the modal dialog only, the picker header is
overdrawn by the MapView and invisible. Proven to predate this change by
A/B against an unmodified build. Back-dismiss works; cosmetic.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:22:24 -04:00
Vitor Pamplona
717461f4d0 fix(chats): draw day headers above the message they introduce
Day separators in group chat did not match the messages under them: a
"Jul 1, 2025" header sat directly above a Sep 23 bubble, and a "Sep 23"
header above a Sep 25 one — while LATER separators in the same
conversation were correct.

The obvious explanation — that a bubble group spanning midnight takes its
header from the group's first message — is wrong. `ChatGroupPosition`
already refuses to group two messages whose formatted dates differ, so a
group cannot cross a day boundary at all.

The real cause is an off-by-one from a false assumption about
`reverseLayout`, stated in the comment that used to sit here: that later
content "draws just above the message". `reverseLayout` reverses the
order of the LIST'S ITEMS, not the content inside one item — each item's
Column still lays out top to bottom. Verified with a throwaway headless
Compose layout probe rather than by argument: with `reverseLayout = true`
and two items each holding a message and a divisor, item 1 (older) sits
above item 0 (newer), but within each item the divisor sits BELOW its own
message.

So the divisor for item i was labelled with date(i) and gated on
date(i+1) != date(i) — the arithmetic was right — but drawn between item
i and item i-1, heading the NEXT, newer message while showing the
previous one's date. That also explains the detail the grouping theory
could not: whenever two consecutive messages fall on the same day the
mislabelled header happens to read correctly, which is why only some
separators looked wrong.

Moving the divisor to the top of the item Column fixes it. Its arguments
were already correct, so nothing else changed — no formatting change, no
grouping change, and one fewer list lookup since it now reuses the
already-computed older note. Subject headers had the same inversion and
are fixed with it.

One call site serves every chat surface — NIP-29 relay groups, NIP-28
public chats, ephemeral chats, live-activity chats, Concord channels,
private DMs, geohash chats and Marmot groups — so all were wrong and all
are fixed.

`markersInGap` immediately below had the identical bug from the identical
assumption: it is handed the bounds for the gap toward the OLDER message
but, composed last, rendered in the gap toward the newer one. Moved for
the same reason.

Not unit-tested, deliberately: the date arithmetic was always correct, so
a pure-function test passes both before and after. Catching this needs a
Compose layout assertion, and `:amethyst` has no Robolectric or JVM
Compose harness — only instrumented tests. The layout probe above is what
established the behaviour; it was deleted afterwards.

Not verified on-device: the visual result is inferred from that probe
plus the reported symptoms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:14:00 -04:00
Vitor Pamplona
f2e42105f7 fix(podcast): stop rendering an episode description twice
The episode renderer shows two blocks: the `description` tag via
`episodeDescription()`, and the event content as markdown. Most feeds put
the SAME text in both, and the thread view renders both (`makeItShort` is
false there), so the whole description appeared twice inside one card —
one author header, one player, two identical bodies, each with its own
"Show More" pill.

The existing guard only suppressed the markdown block when the content
was blank. It now also suppresses it when the content merely repeats the
description, compared on collapsed whitespace so a copy differing only in
wrapping still counts as a duplicate. Feeds were unaffected because they
pass `makeItShort = true` and never rendered the second block, which is
why this only showed on the episode screen.

Found by a device sweep of the untested feature areas.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:07:37 -04:00
Vitor Pamplona
c6a6068486 fix(chats): stop showing ciphertext in DM previews; fix stuck npub names
Two bugs found by a device sweep of the Messages list.

**Raw NIP-04 ciphertext rendered as the message preview.** Rows in New
Requests showed base64 blobs like `0tyoSVovKSK9uDKLUVMs137TD0b+vz…`.
Cause: every decryption branch in `Account.cachedDecryptContent` and
`decryptContent` is gated on `isWriteable()`, and the non-writeable path
fell through to `event.content` verbatim — which for a kind:4 IS the
NIP-04 blob. A read-only (npub-only) login therefore hit this on every
legacy DM room.

Both functions now return null instead of the ciphertext, which closes
the leak on every surface reading them — including the open chatroom
body, which had the identical fallthrough. A new pure classifier backs
the preview and never reads `event.content` for an encrypted kind, so a
future raw fallback cannot resurface there.

Pending and undecryptable are now distinguished on facts the UI actually
has, rather than collapsed into one message: no key at all, or a kind:4
between two other people, is "could not decrypt"; encrypted with our key
a party but plaintext not yet back is "Decrypting…", which resolves
itself when the signer answers. The old code showed the not-found string
for the pending case.

**Group DM titles stuck on npubs while the facepile beside them showed
real names** — and this one is not a display bug at all. Both already
observe metadata through the same flow; the fault is in `User`:

    fun metadata() = metadata ?: UserMetadataCache().also { metadata = it }

Non-atomic lazy init on a plain field, called from BOTH the Compose main
thread (every `observeUserInfo` composition) and the relay/IO threads
(`updateUserInfo`). Two threads can each read null, each allocate, and
one instance is orphaned. A composable collecting the ORPHANED cache
never receives the metadata, so it sits on its pubkey fallback forever
while a sibling that got the surviving instance renders the name — which
is exactly "npubs in the title, names in the facepile, same row", and
why it never recovers.

All six per-user lazy caches are now `@Volatile` with double-checked
locking under one process-wide lock, held only for the allocation. The
store holds tens of thousands of users, so a lock per user would be
worse than the bug.

This likely explains a broader class of "some names resolve and others
never do" symptoms, not just the row that surfaced it.

Not fixed: for a read-only account the open chatroom body now renders
nothing for a kind:4 rather than ciphertext — better, but it deserves the
same "could not decrypt" placeholder the preview row got.

Unverified: that the account which showed the ciphertext was in fact
read-only. Every other route to ciphertext was traced and returns null,
so the non-writeable fallthrough is the only reachable source, but the
device state itself was not captured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 22:01:19 -04:00
Vitor Pamplona
73d59a29bf fix(nip46): gate identity reads on pairing; make decrypt consent informed
Two problems in the remote signer, both about a client getting something
without the user meaningfully agreeing to it.

**`get_public_key` and `get_relays` answered anyone.** Every other method
runs through `ifAuthorized`; these did not, and nothing required a prior
successful `connect`. The service decrypts and dispatches any well-formed
kind-24133 envelope, so anyone holding the `bunker://` URI — pasted into a
malicious app, posted for support, leaked in a screenshot — could ask it
which account it belongs to, without the secret and without connecting.
`get_relays` additionally handed over the inbox relay set. That defeated
the transport/identity split, which otherwise works: the relay-visible
traffic really is anonymous, since the p-tag and author are a transport
key and the payload is NIP-44.

Both now require the client to be paired. The authorizer interface gains
`isPaired` with NO default, so a future authorizer has to state its own
rule rather than silently inheriting "everyone is paired".

`ping` is deliberately left open. It reveals nothing the caller does not
already have — a signer is alive at a pubkey they hold — and first-party
behaviour could be confirmed but third-party clients that ping before
connecting could not be ruled out. Breaking a legitimate handshake to
close a minor oracle is a bad trade. The choice is pinned by a test that
also asserts the pairing check is never consulted, so it stays deliberate
rather than drifting back by accident.

**Decrypt consent showed nothing at all.** The bridge populated the
content preview and raw data only for signing requests, so a decrypt
request produced an empty preview block — no ciphertext, no counterparty,
not even the "Show event" toggle — leaving "AppName wants to read your
private messages" with *Allow always* as the primary button. Meanwhile
the coordinator documented the opposite: "Amethyst decrypts first, then
asks permission to expose." That was never implemented.

Now:
- The counterparty is resolved and shown, so the prompt reads "…read your
  private messages **with Alice**". It never degrades to nothing —
  cached name, else a shortened npub. Knowing *whose* messages is a
  categorically different decision.
- The message is decrypted BEFORE prompting and the plaintext is the
  preview, as documented. It is a local operation and nothing is exposed
  until approval. Failure, blank and hang all collapse to an explanatory
  string under a timeout, so the dialog is never empty and cannot stall.
- A narrower grant is offered ALONGSIDE the broad one, not instead of it:
  `DecryptFrom(counterparty)` keyed `decrypt:<hex>` next to `Decrypt`.
  The dialog's primary button becomes "Always allow for Alice" with the
  broad option demoted. Because the ledger stores an opaque op key, no
  persisted decision migrates and the storage format is untouched.

  Scoping decrypt per counterparty *instead* would have been worse than
  the bug: a DM client would prompt once per conversation, training users
  to approve everything. A narrow option beside the broad one gives
  granularity without the prompt explosion.

Also fixes a latent bug found on the way: `AllowForSession` recorded the
*requested* op rather than the *granted* one, which would have widened a
narrow session grant back to broad.

Verified by three sabotage passes; the tests that stayed green under them
are the intended negative guards. One existing test asserted the buggy
behaviour outright ("public reads are never gated") and was rewritten.

Not done: the batched consent sheet still records the broad op for
"remember" — offering the narrow choice per row there is a UX design
question, not a mechanical change.

Needs a device check before release: the decrypt preview runs the account
signer before consent. That is free for a local key, but an account backed
by an external NIP-55 signer (Amber) may show Amber's own prompt ahead of
Amethyst's.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:28:05 -04:00
Vitor Pamplona
7792c42f1d fix(blossom): bound what a paid server can extract on a 402
Four defects in the pay-to-upload path, all exploitable by a hostile or
merely broken media server.

**No amount cap, and the shown amount was not the paid amount.**
`amountSats()` was display-only — `pay()` never read it, so a server
asking 10,000,000 sats was handled exactly like one asking 10. `pay()`
now takes the amount the dialog showed, re-derives it from the invoice,
and refuses on over-cap, on mismatch with the dialog, and on an
amountless invoice (a payee-chosen amount must never be paid
unattended). The cap is 10,000 sats: real BUD-07 per-blob fees are
single-digit to low-hundreds, so this is one to two orders of magnitude
above legitimate use and caps one prompt's damage without blocking
anyone.

**Unbounded re-prompting.** The post-payment retry caught generic
`Exception` — including a second `BlossomPaymentException` — marked the
target missing, then re-entered the mirror path, so a server that
pocketed the preimage and replied 402 again got an indefinite pay-prompt
cycle. A new prompt ledger allows one prompt per (blob, target) per
user-initiated action; the user's own tap resets it, the automatic
continuation does not.

**Double-spend window.** `withTimeoutOrNull(90_000)` abandoned the wait
without cancelling the in-flight NWC request, so a payment settling at
second 91 was reported as failure and could be paid again. NIP-47
`sendZapPaymentRequestFor` is fire-and-forget with no cancellation and
does not return a request id, so cancelling is not reachable. Instead the
invoice is claimed before sending and released only on a definitive
wallet answer — the timeout path deliberately does not release it, so it
can never be sent twice. Known limit: the claim is process-lifetime, so a
timed-out invoice becomes payable again after a restart; persisting it
needs a real store.

**Unsanitised server text.** The server-controlled `X-Reason` header was
rendered verbatim directly above the pay button, letting a hostile server
assert its own amount ("Pay 1 sat") in what looked like Amethyst's voice.
It is now stripped of control characters and Unicode bidi overrides,
whitespace-collapsed, clamped to 200 chars, and rendered in a distinct
style as `<host> says: "…"` so it cannot be mistaken for our wording.

The two policies are small standalone classes so they are testable
without an Account graph. Verified by disabling all four checks: 12 of 15
tests fail, covering every defect; the 3 that stay green are the intended
negative controls.

Not covered: the background mirror sweep does not go through this handler
and cannot prompt, so it was never exposed to the cap or re-prompt
defects, and it inherits the invoice claim and sanitisation for free.
`pay()` itself is still not end-to-end tested — that needs Account
mocking well beyond this fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:03:20 -04:00
Vitor Pamplona
6045e28830 fix(cashu): validate a token's mint URL before contacting it
`MintHttpClient` only trimmed trailing slashes — no scheme check, no host
check — and `token.mint` comes verbatim from any pasted or posted Cashu
token. Tapping Redeem on a token in someone's note therefore made the
device issue HTTP requests to an arbitrary URL: `http://127.0.0.1:<port>`,
LAN addresses, `169.254.169.254` (cloud metadata), any scheme at all —
plus it disclosed the user's IP to whoever controlled the URL.

Validation now runs in the constructor, so no caller can issue a request
before it. The rule: `https://` to a public host, or `http://` to a
`.onion` host, and nothing else. Onion mints matter — a blanket
"https only" rule would have silently broken every Tor mint.

Rejected hosts cover the private/loopback/link-local/unique-local ranges
plus CGNAT, multicast, reserved and `0/8`: none is a public unicast host,
so allowing them buys nothing and leaks reachability.

The bypasses are what make this worth care, and each has a test:
IPv4-mapped and IPv4-compatible IPv6 (`::ffff:127.0.0.1`, `::127.0.0.1`),
the full `inet_aton` spellings (`2130706433`, `0177.0.0.1`, `0x7f000001`,
`127.1`), trailing-dot hosts, and userinfo disguise
(`https://mint.example.com@127.0.0.1/`) — handled by splitting on the
LAST `@`. The host parse is hand-rolled rather than delegated to
`java.net.URI`/`HttpUrl` precisely because those normalise these forms
inconsistently.

A mint the user added to their own wallet is exempt from the host and
https rules — a self-hosted mint on a LAN is a legitimate setup, and the
threat here is a *pasted, untrusted* token pointing inward, not a mint
the user chose. The exemption never relaxes the scheme check. It is
threaded properly rather than TODO'd: the melt path passes the wallet's
known mints and marks the mint user-configured only on a match; the
wallet ops and CLI pass it directly, since those URLs are the user's own.

Refusal gets its own message rather than reusing the mint-error string,
whose wording would have misattributed our own refusal to the mint.

DNS rebinding is out of scope and noted in a comment — the check runs
pre-resolution and cannot defend against a host that resolves differently
on the second lookup.

Verified by disabling the scheme and host checks: 11 of 20 tests fail,
every rejection case among them, and every allow case still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 21:02:59 -04:00
Vitor Pamplona
c84acddc0c fix(concord): renaming a private channel no longer makes it public
`renameConcordChannel` built `ChannelEntity(name = ...)` from scratch
without reading the standing definition. `private`, `voice` and every
other flag default to false, so the published edition declared the
channel PUBLIC and TEXT. Renaming a private channel silently reclassified
it; renaming a voice channel silently converted it.

The messages themselves stay encrypted — a private channel is
independently keyed and a rename does not rotate that key — but the
Control Plane, which is what every client reads to decide how to present
and gate a channel, now says the wrong thing about it. A moderator
fixing a typo in a channel name should not change who a client believes
may read it.

`deleteConcordChannel` had the same shape: its tombstone also reset the
flags, so retiring a channel reclassified it on the way out.

Both now carry the standing definition forward and change only what they
mean to change.

Found while auditing which write paths re-serialize a decoded entity
rather than re-wrapping it — the same audit that produced the community
list fix. This one is worse than the unknown-key loss it was found
beside, because it discards KNOWN fields with a privacy meaning rather
than fields we merely failed to model.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:44 -04:00
Vitor Pamplona
5c23f8490d fix(concord): stop destroying other clients' data in the community list
The private community list (kind 13302) is documented as wire-compatible
with Armada's `communityList.ts`, whose entry type ends in
`[k: string]: unknown` — unknown keys are part of the contract, and
`ConcordJson`'s own KDoc says shapes are "deliberately client-extensible
(CORD-03/04)". But `ignoreUnknownKeys = true` plus closed `@Serializable`
DTOs meant decode dropped every unmodelled key and encode never restored
it, so **every Amethyst write of a user's list silently stripped fields
another client had written**, across every community in it.

Already proven, not hypothetical: `JoinMaterialWire` declared a
`refounder` field that nothing in the repo reads, so it was parsed and
destroyed on the first write. We only avoided destroying Armada's
`invite_ref`/`excluded_at_epoch` because those were modelled hours ago,
for stranded recovery — the anchor recovery depends on would otherwise
have been deleted on every write.

Each wire DTO's compiler-generated serializer is now wrapped in a shared
`JsonTransformingSerializer` that lifts unknown keys into a bag on
decode and merges them back on encode, with declared fields winning on
conflict. The known-key set is read from the descriptor rather than
hand-listed, so it cannot drift from the DTO. Preserved at the document
root, each entry, the `current` join material, each channel, each
held_root, each tombstone, and everything nested inside `seed`.
`refounder`'s typed field is removed so it round-trips generically.

Two further data-loss bugs surfaced while doing it, both fixed here:

- **`seed` was overwritten with `current` on every write**, destroying
  the immutable join anchor. It is now kept and re-emitted verbatim as a
  raw JsonObject — we never hydrate from it while `current` exists, so we
  have no business rewriting it, and keeping it raw preserves everything
  nested inside for free.
- **`tombstones` were re-encoded as an empty list**, which did not just
  lose their unknown keys: it RESURRECTED communities another client had
  deliberately removed. They are now carried verbatim.

Verified by four separate sabotage passes (no-op the transform,
re-mint `seed`, restore the empty-tombstone write, flip the merge order);
each new test fails under at least one, and every mechanism is covered.

Control-plane re-serialization was audited too and is NOT fixed here:
`compactControlPlane` is safe (it re-wraps the original seal verbatim),
but the user-facing *edit* paths — `editConcordMetadata`, `grant`, and
the channel edits — construct fresh typed entities and re-encode, so they
drop extensions the same way. Fixing those means merging into the head
edition's raw JsonObject on each edit path, which is a larger change than
this should carry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:59:19 -04:00
Vitor Pamplona
f8d8a2b135 feat(concord): recover a membership stranded by a Refounding
A rotation carries only (newRoot, newEpoch, rotator) — no recipient list
— so a member simply left out of the recipient set receives nothing and
is stranded on the dead epoch forever while everyone else moves on. It
applies to any member, the owner included, and cannot be prevented on the
receive side: there is nothing to check.

Armada does not prevent it either; it recovers, and this follows the same
approach. The invite link a membership was joined through is stored as
the anchor, and when that link later resolves to a HIGHER epoch, the
membership merges forward.

Uses Armada's exact wire names so the kind-13302 list stays compatible in
both directions: `invite_ref` (the link in bare `<naddr>#<fragment>`
form, host-stripped so a link minted by a different front end reduces to
the same anchor) and `excluded_at_epoch`, both at the entry level.

Details that decide whether this works at all:

- `merge()` lets a higher-epoch winner inherit the loser's `invite_ref`
  when it has none. Without it a two-device merge silently discards the
  only anchor recovery has, disarming it permanently.
- `adoptConcordRoot` carries `invite_ref`/`excluded_at_epoch` through a
  rotation; it rebuilt the entry field-by-field and would have dropped
  them at exactly the moment they matter.
- Merging forward preserves `heldRoots`, so prior-epoch history the
  member legitimately holds is not lost by recovering.
- Recovery requires a strictly higher epoch and a matching community id,
  so it is monotonic and cannot be steered by an unrelated bundle.

Hooked onto the existing Concord revision tick immediately after
`drainConcordRekeys`, because the two are halves of one problem: a
rotation you were included in arrives as a rekey to drain, one you were
excluded from produces no message at all and can only be found by polling
the link. Rate-limited to 15 minutes per community; an idle tick costs a
map lookup. Only a Live bundle recovers — an expired or revoked link is
not a missed rotation.

Verified by mutating the production code eight ways (dropping the anchor,
dropping heldRoots, dropping the epoch comparison, renaming the wire
keys, removing the merge inheritance, breaking bare-form parsing) and
confirming each produced exactly the expected failures.

Two things this surfaced, both left for their own change:

- Our parser does NOT round-trip unknown JSON keys — `ignoreUnknownKeys`
  plus closed DTOs — while Armada's format ends in `[k: string]:
  unknown`. So every Amethyst write of the community list silently strips
  fields Armada added that we do not model; it already discards the
  `refounder` field we parse but never re-emit. That is live interop data
  loss, caused by us, independent of this work.
- `mergeForward` keeps the entry's existing private-channel grants rather
  than adopting the bundle's, matching what `joinConcordViaInvite`
  already does. If a recovered member should pick up new-epoch grants,
  both paths need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:52 -04:00
Vitor Pamplona
fac1bf5b5d feat(concord): refuse Control-Plane rollbacks with a version floor
`ConcordRefounding.compactControlPlane` re-wraps one edition per entity
when a community rotates epoch, and the ROTATOR chooses which one
survives. The receiving side had no memory: `refold()` folds only the
wraps at the current epoch's Control-Plane address and discards the prior
epoch's buffer, and `EditionFold` accepts whatever it is handed (its
no-genesis fallback anchors at the lowest version present).

So a rotator could publish only version 1 of a chain and omit version 2 —
restoring a revoked role, clearing a banlist, reverting metadata. Every
signature is genuine; this is rollback by omission, not forgery.

Adds a per-entity floor: the version AND hash last successfully folded.

- **No floor (fresh joiner)** — unchanged: genesis anchor, else the
  lowest-version edition as the legitimate compaction bootstrap.
- **With a floor** — the walk is anchored AT the floor: the offered set
  must contain the exact edition already folded (version and hash; a
  same-version sibling is a fork, not our chain), then walks up. A head
  below the floor is structurally unreachable.
- **Gap** (the floor edition is absent) — refuse, and keep the known
  head. Refusing by *retaining* matters here: this fold is recomputed
  from scratch each time, so letting an entity vanish would itself be a
  rollback — a dropped banlist is an unban.

The floor needs no new persistence. It is derived from `heldRoots`, the
rotated-out access roots already persisted in the NIP-44 self-encrypted
kind-13302 list: the session derives each prior epoch's Control-Plane
address from them, folds oldest-first, and takes the resulting heads as
the floor. That survives both a process restart and the session rebuild
`ConcordSessionRegistry.sync` performs at exactly the moment of a
Refounding — which would have destroyed any in-session floor. If the old
planes are not served, there is no floor and behaviour is as before.

Floors are built from AUTHORITY-GATED heads, not raw ones. Without that,
any ex-member still holding a rotated-out root could mint a high-version
edition on the old plane and freeze the entity for every honest client —
a denial of service this change would otherwise have introduced. Covered
by a test.

Verified by disabling both enforcement points: 7 of 12 quartz tests and
the end-to-end commons test fail, and the ones that still pass are
exactly the non-regression cases (fresh joiner, honest compaction,
pass-through without floors).

Known limit: `AuthorityResolver.resolve` folds authorized SUBSETS of the
edition pool and does not carry floors itself; gating happens at the pool
level before the resolver sees anything. Sound, but connectivity checked
on the full set is a weaker precondition than on each subset — passing
floors into the resolver's three folds is worth a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:36:25 -04:00
Vitor Pamplona
30f4638954 fix(concord): re-key observed members on a Refounding
The Refounding recipient set was `Guestbook joins ∪ roster ∪ owner ∪ self`
— it never consulted `observedAuthors`, the members seen publishing to a
channel. `ConcordCommunitySession.allMembers()` already unions them in
(and already subtracts the banned); the rotation path just didn't use it.

Amethyst announces a Guestbook Join, but nothing in the protocol requires
one, so in practice most members of a cross-client community have never
sent one. Every member who had only ever *posted* — no role, no Join —
therefore received no rekey blob and was silently expelled by the next
rotation. Removing a single spammer would quietly strand most of the
community, and it fell hardest on Armada members, who are the bulk of the
roster in the communities we interop with.

Now uses `allMembers()`. Still a floor rather than a census, as its KDoc
says: a member who joined silently, holds no role, and has never posted
leaves no trace to re-key, and nothing here can find them. Stranded
recovery is what brings those back.

Also corrects an overclaim on `ConcordInviteBundle.validate`. Its KDoc
said self-certification stops a bundle smuggling "a false owner or a fake
key for a real community". Only the first half is true: `community_id`
commits to (owner, salt) — both public in any invite — and NOT to
`community_root`, so an attacker can mint a bundle carrying a real
community's id, owner and salt beside a root of their own. A joiner
adopts that root, believes they are in the real community, and posts into
planes the attacker can read.

That is a CORD-05 limitation, not an implementation gap: Armada's
`validateBundle` checks exactly the same thing and also leaves the root
unbound, so a stricter unilateral rule would break interop while
protecting nobody. Verifying the adopted root's Control Plane does not
close it either — sealed editions carry the owner's own signature, so an
attacker can re-wrap genuine owner editions into their plane, which is
what a legitimate compaction does. Closing it needs a spec change
(commit the root into the self-certifying id, or require the bundle to be
signed by a roster-authorized member); the KDoc now says so instead of
claiming a guarantee the code does not provide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:35:50 -04:00
Vitor Pamplona
0f53eb09a2 fix(concord): gate revokes on target rank; drop the owner rotation refusal
Reviewed the previous commit's authority changes against Armada
(gitlab.com/soapbox-pub/armada), the reference Concord client we interop
with. Read for its rules only — Armada is AGPLv3 and Amethyst is MIT, so
no code was taken from it.

It confirmed the role rank gate (Armada checks both directions too: the
author must outrank the position being minted AND the standing position
being replaced) and the banlist check on rotation. It also showed one of
our rules was wrong and one gate was missing.

**Removes the owner's refusal of foreign rotations.** The previous commit
made the owner ignore any rotation it did not author, reasoning that a
BAN-holder could otherwise carry the owner onto a root of their choosing.
Armada does the opposite on purpose — "authority is the roster, never key
possession" — and it is right: an admin legitimately rotating to remove a
spammer would leave the owner alone on the dead epoch, self-inflicting
the strand the rule was meant to prevent, and diverging from the
reference implementation forks communities across clients. The threat is
better answered by the rank gate: with role editions gated, nobody can
escalate themselves to BAN, so BAN-holders are people the owner
deliberately trusted.

**Adds the missing rank gate on grants.** A grant was authorized if the
granter outranked every role it handed out — but a REVOKE carries no role
ids, and `all {}` over an empty list is vacuously true. So any
MANAGE_ROLES holder could strip anyone's roles, the owner's admins
included: promotion was gated, demotion was free. Armada treats a grant
as an action ON the member and requires outranking the target's standing
rank; this now does the same.

Both new tests were verified to fail with the corresponding check
disabled, and each has a companion asserting the legitimate case still
works (an admin can still revoke a moderator beneath it).

Also records what Armada does about exclusion, since we cannot prevent it
receiver-side: it does not try to. A rotation carries no recipient list
there either, so a stranded member instead re-resolves the invite link
they joined through and merges forward to the higher epoch ("stranded
recovery"). Amethyst has no equivalent, so a stranded member stays
stranded — noted at the call site as follow-up work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:10:36 -04:00
Vitor Pamplona
c9c91a8c98 fix(concord): rank-gate role editions and harden rotation authority
Two authority holes, plus the limit that remains.

**Role editions had no rank gate.** Grant editions are correctly gated —
a granter must hold MANAGE_ROLES *and* strictly outrank every role it
hands out. Role editions checked only that the author held MANAGE_ROLES.
Nothing stopped an authorized signer editing a role at or above its own
rank, including the role it holds itself.

So a moderator at position 5 with MANAGE_ROLES could publish one edition
on their own role's chain claiming position 1 and every permission bit,
then a second demoting the real admins beneath them — reaching full
authority over everyone but the owner in two editions. The
rogue-higher-version defence added earlier does not cover this: it drops
editions from *unauthorized* signers, and this signer is authorized.

Role editions are now gated in both directions: an author may not claim a
position at or above its own rank, may not touch a role that already sits
at or above it, and may not hand a role permission bits it does not hold
itself. Deleting keeps only the second rule, so retiring a role beneath
you still works. The owner is unaffected.

**A banned moderator could still rotate the community.** The rekey
receive path authorized the rotator with `effectivePermissions(...)`,
which ignores the banlist, rather than `hasPermission(...)`, which
excludes banned members. Now uses the latter.

**The owner no longer adopts a root someone else minted.** A rotation
replaces the community root, so a rotator who *includes* the owner as a
recipient hands themselves the keys to the owner's own community — the
owner would follow them onto an attacker-chosen epoch. The owner changes
epoch only by rotating themselves.

**Known limit, documented at the call site.** A rotation carries only
(newRoot, newEpoch, rotator) — no recipient list — so a receiver cannot
tell who was omitted. A BAN-holder can therefore still evict the owner by
leaving them out: everyone else adopts, the owner is stranded on the old
epoch. That is not fixable in the receive path; it needs a protocol
change (a recipient commitment the receiver can check, or owner
co-signing). Tracked for CORD-06.

The escalation test was verified to fail with the gate disabled, and a
companion test asserts an admin can still edit a role beneath it, so the
gate is not merely blocking everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 20:00:50 -04:00
Vitor Pamplona
996b800da5 docs(changelog): correct claims the code does not support
An audit of the v1.13.0 notes against the implementation found several
claims that are wrong or overstated. These are user-facing release notes,
so a false claim changes what people believe about their own security.

Corrected:

- **Privacy Lock.** The notes said it gates Messages "behind a password or
  biometric" on "Android and Desktop", with inactivity auto-lock and
  optional preview redaction. It does not exist on Android at all — no
  code, no settings entry — and there is no biometric implementation on
  any platform (`CredentialPrompter` has no implementations). Preview
  redaction persists a setting nothing reads, and the auto-lock is a fixed
  timer, since the idle-reset modifier is never applied. It is also a
  screen gate rather than encryption at rest: the account stays live and
  messages keep syncing while locked. Now described as what ships — a
  password gate on the Desktop Messages and Wallet columns.
- **"Every signature, payment, or data read needs your explicit
  approval."** Only payments require per-use consent; everything else can
  be granted once and reused, and the default trust level auto-signs notes,
  reactions and encryption after a single tap.
- **Web of Trust (GrapeRank)** was listed under app features, but crawling
  and scoring exist only in the `amy` CLI — no app module references it.
  The app consumes NIP-85 cards published by an operator, which is what the
  entry now says.
- **"One-tap trust for your follows' relays"** described bulk-granting
  relays used by people you follow. What exists is category rules evaluated
  per challenge.
- **Pinned web apps "show the app's own icon"** — they render a generic
  placeholder.
- **PoW** contradicted itself: "all cores" in one entry, "half the device's
  cores" in another. The latter matches `PoWPolicy.minerWorkers`.
- **Onion-Location "through every HTTP client"** — the Android app's
  clients only; the desktop, CLI, geode and sandbox blob clients don't
  install it.
- **Relay hardening** claimed REQ refusals stop immediately and failures
  evict "on the first strike"; both take repeated failures, and the
  first-strike eviction applies only to crawls.
- **`bunker://` links cannot be pasted in** — Amethyst only emits them.
- **Git code browser** needs a repository with an http(s) clone URL.
- **Geohash anonymous identity** — the per-area identity is unlinkable, but
  the optional nickname is one global handle, so setting it links your
  posts across areas.
- **Concord ban** is read-time enforcement for everyone else; the banned
  member keeps the keys until a Refounding.

Adds an Upgrading section for the user-visible effects of the per-account
isolation work: sites signed out once, permissions re-asked per account,
and relay logins now prompting under the default remote-signer policy.

Contributor and translator credits are left as they are — several entries
are unresolved npubs and Crowdin-generated usernames, but correcting
attribution is not a call to make from the code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:52:52 -04:00
Vitor Pamplona
2ccd837f30 fix(napplet): sign as the account a surface was launched as
Requests resolved their signer through `sessionManager.loggedInAccount()`
— whichever account is active *right now* — with no binding to the
surface that asked. A full-screen host is a separate activity that an
account switch does not tear down, so:

- Open a site full-screen as A and log in via NIP-07: the page shows A.
- Switch to B in the main app.
- Return to that still-open surface and request a signature: the broker
  handed it **B's** key.

Confirmed on device before the fix. Worse than a mismatched prompt: B's
session was then written into **A's** WebView storage jar, so afterwards
even the embedded tab — which rebuilds correctly and had been verified
correct — displayed the wrong account. Per-account isolation held only
until a full-screen surface wrote a foreign session into a jar. And it
happened silently, because the ledger is per-account+origin and B had
already granted "always allow" for that origin from an earlier session.

`NappletLaunchRegistry.Session` now carries the account that minted the
token, and the broker resolves *that* account out of the cache. This
needs no new machinery to satisfy both halves of the rule: embedded
surfaces are torn down and re-minted on a switch, so they follow the
active account, while a full-screen surface keeps the account it was
opened with. It also extends an argument the code already made — the
sandbox can only act as the napplet it was launched as, because it holds
only its own token; now the same is true of the account.

Fails closed: if the launch account is no longer loaded, the request is
refused rather than falling back to whoever is signed in now.

The same live-account resolution existed on two adjacent paths, fixed
here too:

- Relay subscriptions took the account from a global supplier, so a
  full-screen surface's REQs would target the newly-active account's
  relays while its signatures came from the old one. The account is now
  passed per-open from the launch token.
- `identity.changed` streamed the app's active account, so a page bound
  to A could be told it had become B while signatures still returned A —
  the same desync inverted. It is now bound to the surface's own account
  and reports only that account going away.

Verified on device: with B active, a fresh identity read from a
full-screen surface launched as A returns **A**; the embedded tab still
follows B; both surfaces ran simultaneously under different accounts with
no cross-writes between jars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 19:38:52 -04:00
Vitor Pamplona
c8be65a02e fix(concord): require consent for invite links, enforce expiry, fold the head
Three fixes to the Concord invite and moderation paths.

**Invite deep links redeemed with zero consent.** `ConcordInviteScreen`
called `joinConcordViaInvite` from a `LaunchedEffect` on open, and the
manifest registers `https://amethyst.social/invite/` as BROWSABLE. So a
link on any web page — or a QR code, or a push — silently caused a
connection to up to three ATTACKER-CHOSEN relay URLs decoded from the URL
fragment (disclosing the user's IP to a third party), a Guestbook JOIN
signed by the user's identity published to those relays, and a write to
their private community list. No tap, no preview.

The screen now opens in an awaiting-consent state and only joins from an
explicit Join button. The preview is built entirely from the link itself
— base64url and NIP-19 decoding, both pure in-memory — and touches the
network for nothing: no relay connection, no signing, no publishing. It
shows the relays it would contact so the user can see whom they'd be
talking to. The community name lives inside a bundle only those relays
can serve, so it is honestly reported as unknown until joining rather
than fetched.

**Invite expiry was decorative.** `ConcordInviteBundle.isExpired` had no
production callers at all — the only ones were in a test — so an expired
invite redeemed forever. Expiry is now enforced at `classify`, the choke
point every redeem path funnels through, with its own result and message
so the user knows to ask for a fresh link.

**Moderation read the wrong edition.** `ConcordModeration` used
`firstOrNull` over `controlEditions()`, which is in wrap-ARRIVAL order,
not the folded head. Once an entity had two or more editions the next one
chained off a stale predecessor, forking the chain at an already-used
version, and `EditionFold` then resolved the fork by `minByOrNull` on the
rumor id — a coin flip. Bans were masked by a down-only healing union;
UNBANS and role revocations were not, so they could silently fail to
apply. Both call sites now fold to the true head.

Regression tests assert the fold-head behaviour under two arrival orders
— a single order accidentally puts the head first and passes against the
buggy code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:50 -04:00
Vitor Pamplona
153191e722 fix(nip46): stop auto-signing relay AUTH under the default policy
`REASONABLE` — the default policy on connect — auto-approved kind 22242
(NIP-42 relay auth). The in-code justification was that the event is
ephemeral and bound to one relay and challenge, so it cannot be replayed
elsewhere. That is true and beside the point: the requesting app supplies
the `relay` and `challenge` tags verbatim, so it never needs to replay —
it just asks for a FRESH signature naming any relay it likes.

A paired app could therefore, with no prompt, open its own socket to any
NIP-42 relay, take the challenge, get 22242 signed, and authenticate to
that relay AS THE USER. That yields read access to whatever the relay
gates behind AUTH — notably the kind-1059 giftwrap inbox and its full DM
metadata (who, when, how many) — and burns quota on paid relays, which
bill whoever authenticates.

Amethyst auto-signing AUTH for relays the USER configured is not the same
as letting a third party name the relay; the comment conflated them.

22242 now falls through to ASK. The existing test asserted the vulnerable
behaviour with the same flawed reasoning, so it is inverted here rather
than merely extended.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:33 -04:00
Vitor Pamplona
5fd8be64fd fix(podcast): clamp V4V fee splits so a feed cannot multiply payments
`computeShares` paid each fee recipient `totalMilliSats * split / 100`
with no upper bound on `split`, and `split` comes verbatim from a
kind-30054 episode event that anyone can publish (`ValueTag.parse` is a
bare `fromJson` with no validation). A single `fee:true, split:1000`
recipient was therefore paid TEN TIMES the amount the user chose. The
`remainder` clamp looked like a safety net but only zeroed the honest
recipients; it never touched the fee recipients themselves.

Proven by test before fixing: `split:1000` pays 10x, and two fee
recipients at 60% each pay 1,200,000 millisats for a 1,000,000 zap.

This was reachable in the worst possible place. Streaming V4V pays every
minute, automatically, so a modest multiplier stays under a typical NWC
budget and simply runs — and the on-screen running total tracks the
INTENDED amount, so it reads "100 sats" while 1,000 left the wallet,
while the streaming error handler suppresses the toast. The ordinary zap
button reroutes through the same path for any note carrying a value
block, so it was not limited to the streaming toggle.

Fees are a percentage off the top, so each is now clamped to 100% and the
cumulative total to the remaining budget: the payout can never exceed
what the user chose.

The existing test asserted the right invariant (`sum <= total`) but only
ever ran it on well-formed input, which is why this survived.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:18 -04:00
Vitor Pamplona
7f135a12ab fix(embed): rebuild pinned tabs when the account changes
A WebView's storage profile is fixed at construction, so per-account jars
mean each pinned tab has to be rebuilt on an account switch. Nothing did
that, and the result was a tab that went permanently black: the
logged-in subtree is wrapped in `key(account.pubKey)`, so a switch
disposes and rebuilds it, which closes each sandbox session — but
`EmbeddedTabHost` is process-scoped, so the warm controllers survived.
The rebuilt layer then re-attached fresh views to controllers whose
adapter had already been consumed, and a `SandboxedSdkView` with no
adapter paints only its background, forever.

Three fixes:

- Tabs are torn down and re-armed against the new account's profile when
  it changes, including tabs that were never opened, so none survives
  bound to the previous account's jar.
- `attachView` now recovers instead of silently doing nothing when a
  second view attaches after the adapter was spent, minting a FRESH
  session id — reusing the id let the disposed view's late `close()`
  reap the replacement.
- The re-warm kickoff moved off `LaunchedEffect`. It dispatches through
  the composition's scope, and an account switch floods the main thread:
  measured on a slow device, the sweep started 943-1157 ms after the
  teardown, and the account watcher itself fired 3-4 s late. Running it
  synchronously in the same apply phase drops that to 10-23 ms. The
  suspending work stays in a coroutine; only the kickoff is immediate.

Also gates the load overlay on the tab id rather than a live controller,
so a tab that is re-arming shows the existing spinner instead of a bare
surface — previously the one moment it most needed a cover was the one
moment it had none.

Verified on device across both switch directions, and with profile
detection force-disabled to confirm the recovery path alone prevents the
black.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:59:04 -04:00
Vitor Pamplona
095b156953 fix(napplet): give each account its own WebView storage jar
Embedded web content — browser tabs, napplets, nSites — runs in WebViews
in the `:napplet` process, and their cookies, localStorage, IndexedDB and
service workers were SHARED across every Nostr account on the device.
Nothing in the repo ever cleared them: no `CookieManager`, no
`WebStorage` call anywhere.

So a web app stayed logged in as the previous account after a switch, and
a Nostr web client's localStorage — which routinely holds decrypted DMs,
drafts and follow caches — was readable by whichever account came next.
For a user keeping a pseudonymous npub apart from a real one, the app
could correlate the two itself.

Uses the androidx.webkit multi-profile API (already a dependency) to give
each account its own profile: cookies, storage, geolocation grants and
service workers are all partitioned per `Profile`. Switching accounts
moves to that account's jar and switching back restores the session
intact — isolation rather than deletion, so nothing is lost.

The sandbox never learns which account it is serving. The main process
derives an opaque, domain-separated SHA-256 of the account pubkey,
truncated to 32 hex chars, and passes only that; `:napplet` validates the
shape before use, so a compromised sandbox cannot mint a name for another
account's jar. Both re-arm paths read the current profile at send time,
so a re-created session can never resurrect the previous account's jar.

Where MULTI_PROFILE is unsupported (older WebView), isolation degrades to
lossy-but-safe: cookies and web storage are wiped when the account behind
the WebViews changes, rather than silently shared.

Known gap, documented at the logout hook: a removed account's profile is
not deleted. It cannot be done from the main process — WebView profiles
live in the `:napplet` data directory, and booting WebView here would
collide on it — so it needs a broker message that has the sandbox call
`ProfileStore.deleteProfile`, and that must refuse a profile still bound
to a live WebView.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:58:47 -04:00
Vitor Pamplona
5bce87d76e fix(napplet): make the consent dialog say what a signature really does
The dialog rendered `kind` plus a 160-char content preview and nothing
else. For the kinds whose payload lives entirely in the TAGS, that is
technically true and practically useless — the user saw "publish an event
of kind 3" while approving a replacement of their whole social graph.
kind 10002 redirects every future read and write to attacker relays;
kind 5 deletes notes.

Now, for a replaceable list, it diffs the proposed tags against the copy
already cached on the account and reports what actually changes, rather
than a raw total that hides the dangerous case (a list that silently
drops 130 follows). A single-account edit — by far the common one — names
and pictures that account, so the user can recognize who it is at a
glance. Republishing an identical list says so plainly instead of raising
a false alarm, and a missing baseline falls back to the total and admits
it could not compare. Mute lists diff people only, so the string points
at "Show Event" for muted words and hashtags.

Adds a "Show Event" raw-event toggle mirroring the NIP-46 dialog, which
already had one; the napplet dialog had no way to inspect the full event.

Also fixes an amountless-invoice display bug in the same family:
`LnInvoiceUtil.getAmountInSats` returns ZERO (not null, not a throw) for
a BOLT11 with no amount, so both this dialog and the Blossom pay dialog
affirmatively rendered "0 sats" — telling the user a payment was free
when the amount is in fact unspecified and chosen by the payee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:57 -04:00
Vitor Pamplona
389b460f7a fix(napplet): disclose what connecting to an app pre-grants
Accepting the "Connect to Nostr" dialog bulk-grants every declared
non-payment capability as ALLOW_ALWAYS — RELAY, IDENTITY, STORAGE,
RESOURCE, UPLOAD, NOTIFY, KEYS — unless the user picks PARANOID. The
dialog never showed that: `buildConnectInfo` did not receive `declared`
at all, so the user approved a set on the strength of a title and an
icon.

`SignerConnectInfo.requestedPermissions` already existed for exactly this
("shown so the user gives INFORMED consent before those ops are
pre-granted") and is populated by the NIP-46 nostrconnect path. The
napplet path simply never filled it in. This threads `declared` through
`NostrConnectPrompt` to `buildConnectInfo`, which lists the capabilities
that actually get pre-granted — SHELL/THEME never prompt and VALUE is
per-use, so listing those would overstate what accepting hands over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:57:12 -04:00
Vitor Pamplona
dbf15a2146 fix(napplet): scope applet grants per app and per account
Every napplet/web-app grant was keyed by applet coordinate alone
(`<appAuthor>:<identifier>`), which carries no account. The stores and
ledgers are process-wide singletons shared by all accounts, so grants
leaked in two directions:

- **Across apps.** `NappletBroker.sessionAllows` held a bare `op.key`
  ("sign:1"), and the check ran *before* the per-app ledger lookup. One
  app's "Allow for this session" therefore authorized that op for every
  other applet and browser origin, silently, for the broker's lifetime.

- **Across accounts.** A grant made under one npub authorized the same
  applet under every other npub on the device. For a user keeping a
  pseudonymous account separate from a real one, an app authorized by
  one could sign as the other with no prompt — defeating the point of
  separate accounts.

NIP-46 already solved this shape correctly: `Nip46PermissionAuthorizer`
namespaces by account (`nip46:<signer>:<client>`) and keys session grants
by `(coordinate, op)`. Its comment even claims it "mirrors the napplet
broker's sessionAllows" — the mirror was backwards. This adopts the same
pattern on the napplet side:

- `sessionAllows` and `NappletPermissionLedger.session` are keyed by
  account + coordinate + op.
- Napplet storage and the capability store namespace keys by account,
  read at call time so a switch moves reads/writes with no rebuild.
- The signer ledger is deliberately NOT account-scoped at the store: it
  is shared with NIP-46, whose sessions run for a specific account rather
  than the active one, so scoping it there would break a background
  bunker. The napplet path namespaces its own coordinate instead.

Also here, found while scoping:

- `identity.watch` consulted only the manifest declaration and never the
  ledger, bypassing a standing DENY — it short-circuits before
  `NappletBroker.handle`, where the "a standing denial always wins" rule
  lives. It now applies that rule itself.
- `DataStoreNappletStorage.keys()` filtered on a space separator while
  keys are written with NUL, so it silently matched nothing and always
  returned an empty list.

Existing grants live under the old un-namespaced keys and are not
migrated: users are re-prompted once. Migrating would attribute grants
made under the broken model to whichever account is active, preserving
the bug.

Regression tests cover both leak directions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 18:56:58 -04:00
Vitor Pamplona
ee3b8786d4 Merge pull request #3648 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:15:02 -04:00
Vitor Pamplona
49b93db532 Merge pull request #3647 from vitorpamplona/claude/changelog-new-version-y1y331
docs(changelog): add v1.13.0 release notes
2026-07-19 15:13:29 -04:00
vitorpamplona
710c68d6aa chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 19:09:10 +00:00
Vitor Pamplona
824f9898de Merge pull request #3645 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 15:06:36 -04:00
Claude
fb730d7ea8 docs(changelog): add v1.13.0 release notes
Add docs/changelog/v1.13.00.md — the v1.13.0 "Web Apps, Communities & Git"
release notes — and link it at the top of the changelog index.

Covers the full v1.12.6..HEAD range: the in-app Browser and NIP-5D/5A web
clients, Concord communities, NIP-34 git collaboration, Location Channels, the
NIP-46 Remote Signer and Privacy Lock, NIP-42 relay login permissions, NIP-29
Groups, the redesigned Messages inbox, Blossom file sync, Podcasting 2.0,
proof-of-work (NIP-13), negentropy (NIP-77), GrapeRank, accent theming, and the
Desktop, amy CLI, Quartz, and Geode work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JrPNt4FchqArpMtAHHfqGi
2026-07-19 19:06:03 +00:00
vitorpamplona
fe761ab7f2 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 14:44:14 +00:00
Vitor Pamplona
768e19ff9b Merge pull request #3646 from vitorpamplona/claude/update-all-dependencies-h7vnw4
chore(deps): update dependencies across all modules
2026-07-19 10:41:30 -04:00
Claude
18c0c4291f chore(deps): update dependencies across all modules
Bump to latest stable (or latest within an existing pre-release track),
skipping stable→alpha/beta jumps for production safety.

- kotlin 2.4.0 → 2.4.10 (+ kotlinTest, compose compiler); ksp 2.3.9 → 2.3.10
- composeBom 2026.06.00 → 2026.06.01; composeRuntimeAnnotation 1.11.3 → 1.11.4
- firebaseBom 34.15.0 → 34.16.0; jacksonModuleKotlin 2.22.0 → 2.22.1
- kotlinxCollectionsImmutable 0.5.0 → 0.5.1; sqlite 2.6.2 → 2.7.0
- composemediaplayer 0.10.0 → 0.11.3; zoomable 2.12.0 → 2.13.0
- jlatexmath 1.4 → 1.5; jna 5.14.0 → 5.19.1 (nestsClient)
- benchmark 1.5.0-alpha06 → alpha07; genaiPrompt 1.0.0-beta2 → beta3
- bump DisableCacheInKotlinVersion guard 2_4_0 → 2_4_10 (iOS test workaround)

appfunctions held at alpha09 (appfunctions-service has no alpha10 published).
AGP 9.3.0, gms 4.5.0, kotlin stable, ktor, coil, media3, navigation, and other
androidx libs already at their newest stable; alpha-only newer releases skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TreeiMiHrhWce6PbnA5qWz
2026-07-19 14:06:37 +00:00
Vitor Pamplona
618664c45a Merge pull request #3644 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-19 09:46:56 -04:00
vitorpamplona
7b5e5f572b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 02:42:47 +00:00
Vitor Pamplona
f875d2c397 Merge pull request #3643 from vitorpamplona/claude/nip29-location-geohash-ux-890wjz
Map-first geohash location UX: picker, teleport, follow, retarget
2026-07-18 22:39:58 -04:00
Claude
b94303a77a fix(location): audit fixes — stuck GPS spinner, main-thread geocode
- "Use my location" could spin forever after a permission denial: the reset was
  in an isGranted-keyed effect that never re-ran on false→false. Use
  rememberPermissionState's result callback, which fires on grant AND denial, so
  the spinner always clears.
- Forward-geocode search ran the blocking Geocoder on the UI thread on Android
  < 13 (ANR risk). Run the search on Dispatchers.IO.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 02:11:59 +00:00
Claude
a87d4688a8 fix(location): single location control on the geo-post screen
The geo-post composer showed two locations: the static external-id marker at the
top and the new "Posting to · Change" row below the message. Drop the duplicate
and, for the geohash case, render the interactive channel control (with retarget)
in the marker's place. Non-geohash external ids still use DisplayExternalId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:50:02 +00:00
Claude
fb9ccbaad0 feat(location): follow a teleported place; retarget geo-posts
1) Follow this location from the feed: when a top-nav Geohash filter is active
   (e.g. after teleporting), the filter header shows a bookmark toggle to
   follow/unfollow that place (kind 10081), so a teleported spot can be saved to
   your locations — the "add to my interests" step. Respects read-only accounts.

2) Retarget a geo-post: the geohash comment composer now shows "Posting to <place>"
   with a Change action that opens the map picker and re-scopes the post's NIP-73
   geohash channel (CommentPostViewModel.geohashScope/setGeohashScope), instead of
   being locked to the feed's channel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 01:25:24 +00:00
Vitor Pamplona
b801a6b138 Merge pull request #3639 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 21:13:04 -04:00
Claude
7c37a8691c feat(topnav): teleport to a place from the feed filter dialog
Add a "Teleport to a place…" entry to every top-nav feed filter that offers
"Around Me" (home, video, discover, notifications, products, music, git,
podcasts, relay-group discovery, …). Selecting it opens the shared map picker;
on confirm the chosen place becomes this screen's TopFilter.Geohash feed.

Implementation is centralized in FeedFilterSpinner (no per-top-bar changes): a
UI-only TopFilter.TeleportPicker sentinel is intercepted there to open the
picker, then forwarded through the normal onSelect path as a TopFilter.Geohash —
which each screen's existing changeDefault*FollowList already handles. The
spinner header falls back to the raw selection so a teleported (unfollowed)
geohash still shows its place name.

Follow-up (not in this commit): a "follow this location" action so a teleported
place can be saved to the kind-10081 geohash list from the feed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:55:43 +00:00
Claude
1445aea849 feat(location): open the picker at the user's location when permitted
If the picker opens with no seed and location permission is already granted, fly
to the user's current position instead of the neutral world view. Runs once and
never prompts — it only consumes already-granted permission; on denial or no fix
it stays at the world view as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:48:24 +00:00
vitorpamplona
6ba4ab0a4b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-19 00:27:16 +00:00
Vitor Pamplona
035c50421c Merge pull request #3642 from vitorpamplona/claude/cli-module-deep-review-le4dsj
amy CLI overhaul: exit-code/JSON contract v1, per-command help, contract tests, docs, and thin-layer extractions
2026-07-18 20:24:28 -04:00
Claude
c6b2bad0a9 feat(location): calmer map, cell highlight, zoom on area size
- Mute the busy MAPNIK tiles in light mode (desaturate + gentle lighten), the
  light-mode counterpart to the existing dark tile filter. Applied to both the
  interactive picker and the preview thumbnail.
- Outline the selected geohash cell on the map so the user sees exactly which
  region a post/filter covers; the rectangle follows the pin and resizes with the
  area-size level.
- When the area-size level changes, animate the zoom so the new cell is framed.

LocationPickerMap gains zoomTo/highlight/highlightColor params (all guarded to
rebuild only on change, preserving the per-frame-cheap update path).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-19 00:21:02 +00:00
Claude
4efb2cca98 fix(quartz,cli): audit fixes — bounded drains, no event loss, honest verdicts, false-reject traps
Adversarial audit of the PR's own changes (8 finder angles, verified
before fixing). Quartz core:

- fetchAll-family drains get a wall-clock ceiling (maxTotalMs, default
  10x the idle window, delay()-watchdog: cancellable and virtual-time
  testable). The pure idle window was unbounded when a relay trickled
  events forever — sandboxed napplet queries, set -e fetches, and
  marmot await stuck inside one drain. Streaming relays still finish.
- The suspending onEvent hook no longer runs inside a cancellable
  timeout scope (an expiring window could cancel verifyAndStore
  mid-write and silently drop a received event); the timeout is armed
  only when the channels are dry (no per-message timeout-job churn).
- fetchAll is a projection over fetchAllWithHooks: fixes its
  unsynchronized events/seenIds mutation from concurrent socket
  threads and deletes the duplicate loop + per-event activity channel.
- publishAndConfirmDetailed regains its only-responders contract
  (synthetic no-response entries no longer render as 'relay rejected
  your message' in app callers); results built by pure associateWith;
  shared failure-reason constants + PublishResult.isTransportFailure.
- NIP-65 mutations: split read+write r-tags for the same URL now merge
  to BOTH instead of last-wins dropping a facet (+ test).
- TcpProber's 128-thread pool drains after 60s idle.

CLI:

- publishGuard: all-transport failure exits 124 as timeout; rejected/1
  is reserved for an actual OK-false answer.
- --help anywhere in argv is hoisted centrally; 'amy notes post "x"
  --help' prints usage instead of publishing.
- rejectUnknown false-reject traps fixed: geochat --no-fetch behind an
  early return, and 13 elvis-alias short-circuit sites read eagerly.
- Aliases load once per Context and only match name-shaped inputs (no
  shadowing a real npub/NIP-05/hex); stderr color requires a
  positively-known terminal (TERM sniff polluted captured logs).
- Relay-CSV strictness unified on RawEventSupport.relayFlag (post,
  graperank publish/followers/register no longer silently drop
  malformed URLs); Args.timeoutMs(+OrNull) replaces 27 hand-rolled
  conversions, all strict; offer/debit --timeout > 3600 rejected with
  a 'looks like milliseconds' hint; NPub.create idiom; stale jq .id in
  the marmot reactions harness; printUsage drift (offer pay --with,
  profile --clink-offer, search --kind).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-19 00:17:43 +00:00
Claude
787fd99d1e fix(location): picker polish — open zoom, search pill, chip rows
- Open the seeded picker at a zoom that fits the geohash cell's precision
  (region opens wide, building opens tight) instead of a fixed zoom.
- Hide the search field's rectangular outline so its square corners no longer
  poke through the rounded pill (border colors → transparent).
- Drop the 48dp minimum touch-target on the area-size chips so wrapped rows sit
  8dp apart (matching the horizontal gap) instead of ~24dp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:57:06 +00:00
Claude
b20efb3147 fix(location): map drag interception + wrap precision chips
- The interactive picker map didn't tell ancestor views to stop intercepting
  touches, so a horizontal drag on the Teleport map got stolen (opening the nav
  drawer) instead of panning. Add the same requestDisallowInterceptTouchEvent
  touch listener LocationPreviewMap already uses.
- The precision chips were a single horizontally-scrolling Row; make them a
  FlowRow so "Region · ~1250 km … Building · ~38 m" wraps onto multiple lines.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:45:44 +00:00
Claude
78e641b3b2 fix(location): audit fixes for the geohash picker
Bugs:
- "Use my location" could spin forever when a fix never arrives (location off,
  indoors, permission granted but unavailable). Wait with a 20s timeout and stop
  on any terminal outcome so the button always resets.
- With no seed, an initial osmdroid layout-scroll at the opening center could be
  mistaken for a pick, auto-selecting the mid-Atlantic and enabling Confirm before
  the user moved. Gate selection behind a real pan/search/GPS/tap (hasSelection).

Performance:
- The map's AndroidView update ran setColorFilter + overlays.removeAll +
  invalidate() on every scroll-driven recomposition. Move the dark-mode tile
  filter to a theme-keyed effect, and rebuild the marker + invalidate only when
  the marker point actually changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:43:02 +00:00
Claude
962d1706dc fix(quartz): fetchAll-family timeouts are idle windows, not absolute deadlines
The fetchAll/fetchAllWithHooks accessories wrapped their whole
collection loop in one withTimeoutOrNull, so a relay actively
streaming a large backlog was cropped mid-delivery the moment the
absolute deadline hit — even though the loop already has proper
terminal conditions (per-relay EOSE / CLOSED / cannot-connect) and the
timeout's only real job is stall detection.

timeoutMs now measures the delta since the LAST message: every event
or terminal signal resets the window (fetchAll gains a conflated
activity ping so event progress is visible to its wait loop), and only
a full window of silence ends the fetch early. fetchFirst/count keep
absolute waits (single-response — idle and absolute coincide), and
subscribe's duration timeout stays absolute by design (a live stream
has no terminal state).

Since the pages/pool helpers delegate to fetchAll, pagination inherits
the semantics. This also changes app-side callers of these accessories
— in their favor: the timeout only ever fired on slow relays, exactly
when cropping loses data.

New commonTest suite pins the behavior: a relay emitting every 200ms
under a 300ms window streams to completion (10/10 events); a stall
ends one window after the last message, not after the start; EOSE
still returns immediately. CLI docs reworded (--timeout = idle window).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:39:25 +00:00
Claude
620fc465df feat(cli): publish results carry each relay's rejection reason; converge output shapes
With no external consumers yet, converge the --json surface to its
ideal shape in one pass:

- quartz gains publishAndCollectResults: the NIP-01 OK message, connect
  errors, and silent timeouts now survive as PublishResult(accepted,
  message) per relay instead of dying in a debug log. The existing
  boolean APIs delegate unchanged. Silent relays are reported as
  'no response within timeout' rather than omitted.
- Context.publish returns the rich map; the new
  RawEventSupport.ackFields(ack) is the one canonical projection every
  publisher emits: published_to (urls) + rejected_by as
  [{relay, reason}] — 'why didn't it post' now answers itself, in
  partial failures and in the rejected error alike.
- author/pubkey rule enforced module-wide: 'author' is the key that
  signed an event (feed/search/dm/message list items), 'pubkey' an
  identity being described; profile show and outbox add the bech32
  npub beside the hex when the user is the primary subject.
- Event-list items converge on event_id/author/created_at/content
  (dm, feed, search, marmot message, geochat, concord).
- Byte counts standardize on *_bytes keys (blossom/nsite size ->
  size_bytes); the text renderer drops the fragile bare-'size'
  heuristic and colors stderr progress independently of a piped
  stdout.
- Error details are sentences everywhere (not bare gids); dead Result
  class removed from the quartz publish accessory.

Docs updated (DEVELOPMENT output conventions, README rejected example).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:30:06 +00:00
Claude
fea9bcb8ea feat(composer): extend map location picker to all composers
Lift pickedGeoHash into the shared ILocationGrabber interface and add a shared
GeoHashPostSection composable (GPS default + "pick on map"), then wire it into
every remaining composer: long-form, classifieds, private DMs, public messages,
NIP-22 comments, channel messages, and nest messages. Each ViewModel now honors
the map-picked geohash over GPS at build time and round-trips it through drafts;
each screen renders the shared section in place of the GPS-only LocationAsHash.

The short-note composer is refactored onto the same shared section (its local
copy removed). The geohash-chat "New location channel" screen already reaches the
picker via its Teleport card, which now uses the shared, polished picker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 23:29:46 +00:00
Vitor Pamplona
43704772a7 Merge pull request #3641 from vitorpamplona/claude/nip29-group-load-perf-wz4yca
NIP-29 group chat: split state (always-on) from content (paginated)
2026-07-18 19:15:22 -04:00
Claude
8d3a47b8f8 refactor(cli): split GrapeRankCommand into graperank/ sub-files; TCP prober to quartz
The 1500-line GrapeRankCommand (15 sub-verbs in one object, 7.5x the
module's 200-line smell threshold) becomes a 165-line dispatch that
delegates to graperank/{Crawl,Score,Publish,Operator,Support}. The TCP
reachability pre-probe with its dedicated 128-thread dispatcher is
transport infrastructure, not command code — it moves to quartz
nip66RelayMonitor/reachability (TcpProber, jvmAndroid). Pure move, no
behavior change; cli tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 23:00:15 +00:00
Claude
82f041b1f5 docs(cli): reconcile README/ROADMAP/DEVELOPMENT/tests with reality
The docs had drifted badly behind the code: geochat was documented
nowhere, concord/zap/search/podcast20/nsite-publish were README-
invisible, the ROADMAP matrix contradicted its own nak table on six
shipped features, and DEVELOPMENT described the legacy FS event store
as the default when SQLite is.

- README: sections for search, zap (incl. --with auto-pay), podcast20,
  nsite/napplet (all four sub-verbs), concord (13 verbs), geochat, and
  a 'Which chat system?' comparison table; output section rewritten for
  the new contract (exit-code derivation, rejected, unknown-flag
  errors, -- terminator, per-command --help); layout diagram fixed for
  the SQLite default + operator/ + concord.json; the bunker nak-interop
  claim reworded honestly; RECIPES.md linked.
- ROADMAP: stale new-item rows flipped (follow, outbox, Blossom,
  bunker, search; zap partial), rows added for relaygroup/geochat/
  concord/nsite/napplet/podcast20/CLINK/fof, orphaned thread note
  fixed, test-suite section updated.
- DEVELOPMENT: canonical error-code list pinned, exit-code rule
  documented, no-prompts carve-outs, refreshed architecture tree +
  command template (USAGE/route(help=)/rejectUnknown/publishGuard),
  SQLite store section, testing table covers the new JVM suites.
- tests/README: all ten suite dirs listed, JVM contract suite noted,
  mis-spliced marmot row repaired.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:59:37 +00:00
Claude
7b32ca1da3 feat(composer): add "pick on map" location to the short-note composer
The composer could only attach the current GPS location (fixed ~5km), which
fails when GPS is denied/unavailable and can't tag a post with a different
place. Add a "pick a place on the map" action that opens the shared
GeohashLocationPickerDialog and stores the result in
ShortNotePostViewModel.pickedGeoHash, which overrides the GPS fix at build time
and round-trips through drafts. Picking a place also skips the GPS permission
prompt. The existing "use my location" GPS flow is unchanged; this is additive.

Scoped to the short-note composer for now; the other ILocationGrabber composers
(long-form, classifieds, DMs, comments, channel/nest messages) can adopt the
same pickedGeoHash override + section in a follow-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:57:55 +00:00
Claude
6e664b2db6 refactor(cli): extract the drain loop to quartz accessories; split Context per domain
Context.drain/drainAllPages/requestResponse re-implemented the
subscription state machine quartz already ships in
relay/client/accessories — the CLI-specific needs (per-event
verify-and-store hook, dead-relay collection, pending-on-auth) now live
in an option-rich fetchAll variant there, and Context keeps thin
adapters. The per-domain sections bolted onto Context (Cashu seed
warming/snapshot/restore counters; the Concord stream-key AUTH
registry) move to CashuContext/ConcordAuth, with the NUT-09 restore
counter rule shared via commons CashuWalletOps so the CLI and Android
can't drift. Context.kt: 1246 -> 950 lines; behavior unchanged
(cli tests green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 22:55:56 +00:00
Claude
8658dcd512 refactor(geohash): reuse the shared picker in the Teleport screen
Extract the picker body into GeohashLocationPickerContent (chrome-less: map +
search + use-my-location + precision + readout + confirm), leaving
GeohashLocationPickerDialog as a thin dialog wrapper around it.

Rewrite GeohashTeleportScreen to host that shared content inside its own scaffold,
passing a teleport-specific confirm label and action (follow the cell + open the
chat with the teleport flag). This dedupes the map/precision/city-name code that
Teleport previously copied, and gives Teleport place search, "use my location",
dark-mode tiles, area-size chips, and the animated pin for free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 22:36:47 +00:00
Vitor Pamplona
ad2c232868 build(tor): rebuild libarti_android.so for the SOCKS reply-code fix
Regenerates the shipped native library from the preceding lib.rs change. Kept
as its own commit so the binary diff is isolated and can be audited against the
source change independently.

Built with the canonical reproducible-build path (/tmp/amethyst-arti-build) and
the pinned 1.94.1 toolchain; ARTI_VERSION and Cargo.lock are unchanged, so
lib.rs is the only input that moved. Note that the build path is embedded in
the output, so overriding ARTI_REPRO_DIR produces different bytes — the
artifact committed here comes from the canonical path.

verify-reproducible.sh passes: two clean builds produced identical bytes.
  arm64-v8a  3293f9fd1663f9481972f4b705fe80feb9650271b5a3b4258420501d6375bb3e
  x86_64     a5a5864741a09708cd0b6e44ef3ba28298b4862872084d98e172487d6b12720b

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
dd6e7e2852 fix(tor): map Arti errors to accurate SOCKS reply codes
The JNI wrapper answered every failed client.connect() with SOCKS reply 0x05,
so a domain that no longer exists, an exit that timed out, and a genuinely
refused port were indistinguishable. Java renders 0x05 as
SocketException("SOCKS: Connection refused"), so the whole failure taxonomy
collapsed into one opaque string and callers could only apply their most
generic retry policy. On a cold start with the default settings — torType
INTERNAL and newRelaysViaTor true, so the entire outbox fan-out is routed
through Tor — 639 of roughly 768 relay failures arrived this way.

This is why the DNS classification added earlier was effectively dead code for
default users: name resolution happens at the exit, so UnknownHostException is
never raised locally.

socks_reply_for() maps ErrorKind onto the codes Java surfaces with distinct
messages, so a caller can tell "this relay is gone" from "this circuit had a
bad minute". No new dependency — arti-client re-exports ErrorKind and HasKind.

RemoteHostResolutionFailed is mapped to 0x04 even though Arti documents it as
retryable, because an exit's resolver failing is not proof the name is dead.
The caller's response to 0x04 is a bounded backoff rather than permanent
condemnation, which is a retry, just a slower one; probing the relays that
produced this error found 17 of 21 to be NXDOMAIN from an ordinary resolver, so
the conservative reading costs far more than it saves. Changing that arm to
0x01 restores Arti's reading if that tradeoff is ever judged wrong.

Verified on device: Java now reports "SOCKS: TTL expired", "SOCKS: Host
unreachable", "SOCKS: Connection not allowed by ruleset" and friends where
everything was previously "SOCKS: Connection refused".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
850c3e58e4 feat(relay): debug-only per-relay cold-start census
Nothing could answer "what did every relay in the pool actually do, and why did
the ones that failed, fail". RelayStats has counters but no dump path and no
failure taxonomy; RelaySpeedLogger counts events per second, not connection
outcomes; RelayLogger prints one line per event, which at the few-hundred-relay
cardinality of the outbox model is thousands of lines to grep rather than a
table to read.

BootRelayDiagnostics buckets connection failures by cause (separating a Tor
SOCKS refusal, which says nothing about the relay, from DNS, TLS, an HTTP
upgrade rejection, a timeout, or a genuine refusal), counts REQ/EOSE/CLOSED per
relay with CLOSED split by NIP-01 machine-readable prefix, and records
time-to-first-open and time-to-first-EOSE. It dumps a rollup plus two tables at
20/45/90s: the relays that cost dials and returned nothing, and the relays
actually carrying the boot — the latter so a suppression change can be checked
for coverage loss instead of only counting CLOSED reduction.

Debug builds only, attached like the other loggers in AppModules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
e66550091d fix(relay): send an unresolvable host straight to the long backoff
A relay whose domain no longer exists (lapsed registration, decommissioned
host) was treated like a busy relay: the backoff doubled from 1s and spent
about ten dials climbing to the ceiling it was always going to reach. An HTTP
upgrade rejection already jumps straight there; a name that does not resolve
deserves the same.

Matching is on the exception type rather than the message because the message
is localized and platform-specific — Android says `Unable to resolve host "x"`,
JVM on macOS says `nodename nor servname provided, or not known` — while the
class name is stable. That is why onCannotConnect appends it in the first
place. Neither message ends with "Host unreachable", so the existing check
never caught DNS failures.

Being this eager is only safe because the verdict is cheap to revisit: a DNS
answer is a property of the network, not of the relay (a captive portal or a
filtering resolver forges NXDOMAIN), and both a network-identity change and a
transport change now clear the backoff outright. The test pins that round trip,
not just the classification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
b1b6190d2f fix(relay): forgive reconnect backoff when the network or transport changes
A relay's reconnect backoff was process-global and network-blind: the delay
earned on one network was still being served out on the next. The only reset
signal was OkHttpClient reference identity, which is rebuilt off the metered
bit, so it fired for wifi<->cellular and nothing else. Wifi A -> wifi B, a VPN
coming up, a captive portal clearing, and metered-wifi -> cellular all left
every relay parked on a penalty earned against a network the device had left —
up to five minutes of silence on a network that might reach the relay instantly.
The transient Off that would have reset things is swallowed by the 200ms
debounce in ConnectivityFlow, so it never rescued those cases.

Key the decision on ConnectivityStatus.Active.networkId instead, which is the
same signal SurgeDns already uses to stale its cache, and treat a genuine
network change as a full pool rebuild: every socket is bound to an interface
that no longer carries traffic, and needsToReconnect() cannot see that because
it only compares the proxy and the timeouts.

Also treat a Tor policy flip as a transport change. Flipping a Tor toggle while
Tor is already up leaves both OkHttpClient references identical, so a relay
whose transport just changed kept waiting out a backoff earned on the other
transport. Only TorRelaySettings is compared, not the relay sets that
TorRelayEvaluation also carries — those churn while an account's relay lists
load (observed firing three times in one cold start), and forgiving the whole
pool every time any list updates is far more damage than it repairs.

Adds IRelayClient.resetBackoff() (default no-op, so the existing fakes and
BleNostrClient are unaffected) rather than reusing ignoreRetryDelays, which
only skips the gate for a single attempt and still doubles the stored delay —
a relay that failed that one dial came back worse off than before.
INostrClient.resetBackoff() is deliberately separate from reconnect(): the
latter debounces, so folding this into a coalescing command would let a later
request silently drop the reset.

The decision table moves into RelayProxyClientConnector.apply() so it can be
exercised directly, without a debounce and a shared StateFlow in the way.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 18:35:25 -04:00
Vitor Pamplona
bff7592383 Merge pull request #3640 from vitorpamplona/claude/allsettingsscreen-reorder-9ov2tu
Reorder settings menu entries and update icons
2026-07-18 18:32:57 -04:00
Claude
11590b7081 feat(location): polish the geohash picker and generalize it
Refinements after the initial map picker:

- Dark mode: apply the night tile filter to the interactive LocationPickerMap
  too, so the picker matches the app theme instead of showing a bright map.
- Search results: the place search now shows a tappable list of candidates
  (with human place names) instead of silently jumping to the first hit.
- Precision chips now show approximate area sizes (e.g. "City · ~5 km").
- The center pin lifts with an animated shadow while the target moves.

Generalization: the picker's own strings are renamed from relay_group_location_*
to neutral location_picker_* keys so any caller can reuse
GeohashLocationPickerDialog, not just the NIP-29 group form. The group-form card
strings stay namespaced. LocationPreviewMap gains a configurable aspectRatio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 21:21:57 +00:00
Claude
a4713c89c2 docs(cli): add RECIPES.md — task-shaped walkthroughs for the big subsystems
Seven end-to-end recipes (own relay + NIP-86 admin, NIP-46 bunker both
directions, Marmot group chat, NIP-60/61 Cashu wallet, NIP-5A nsite
publishing, GrapeRank provider pipeline, scripting patterns) so the
docs teach jobs, not just verbs. Reflects the new contract: per-command
--help, alias resolution in user slots, published_to/rejected errors.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:13:23 +00:00
Claude
d8584a7935 feat: reorder App Settings entries and refine Home/Messages rows
Reorder the App Settings section in AllSettingsScreen to: Privacy Options,
UI Preferences, Home, Messages, Notifications, Compose Settings, Reaction
Row, Bottom Navigation Bar, Profile UI, then the remaining rows.

Rename the "Home Tabs" row to "Home" (reusing route_home) and switch the
Messages row icon to MaterialSymbols.Mail to match the Messages screen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYck7FKoLzoaomnjjLFLzi
2026-07-18 21:12:04 +00:00
Claude
c28c8af13f feat(nip29): map-based location picker for group creation
The NIP-29 group create/edit form asked for the group's location as a raw
geohash text field (placeholder "u0nd") — unusable, since nobody knows their
geohash, so the discovery geo-filter it feeds stayed empty.

Replace it with a first-class, map-first location experience, reusing the
pieces that already power the Geohash-chat Teleport screen:

- New GeohashLocationPickerDialog: a full-screen picker where the user pans a
  map under a fixed center pin, searches for a place by name, or taps "use my
  location" (device GPS). Precision chips (GeohashChannelLevel) control the
  area size; the resolved place name is shown via LoadCityName. Never surfaces
  a raw geohash.
- New ForwardGeolocation service (Geocoder.getFromLocationName) powering search,
  mirroring the existing ReverseGeolocation.
- Extend LocationPickerMap with backward-compatible recenter/recenterZoom and
  onCenterChanged hooks for the center-pin interaction; extend LocationPreviewMap
  with a configurable aspectRatio for the form's map thumbnail.
- Rework the form's location field: an inviting empty-state card, a filled card
  with a themed map thumbnail + place name + geohash + clear, and a collapsed
  "enter manually" field so power users can still paste a known geohash.

The ViewModel's geohash state stays the single source of truth, so the publish
path (parseGeohashes -> kind-9002 EditMetadataEvent) is unchanged. Adds the
MyLocation glyph and regenerates the Material Symbols subset font.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YU8YLcjH9ALr4PgdAkGPZh
2026-07-18 21:07:44 +00:00
Claude
7ea6920679 refactor(nip65): move read/write-marker merge semantics from the CLI into quartz
The kind:10002 facet-merge rules (adding a write marker to a read-only
relay promotes it to BOTH; removing one facet of BOTH demotes to the
other; removing the last facet drops the relay) lived as private
helpers in the CLI's RelayCommands. Any frontend that edits a NIP-65
list needs them, so they now live in quartz nip65RelayList as
AdvertisedRelayListMutations (applyFacet/addFacet/removeFacet/setFacet)
with commonTest coverage. Behavior unchanged; the CLI rewires to the
shared functions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 21:03:56 +00:00
Claude
80be7b909b test(cli): bare 'amy' exits 2; key generate rejects unknown flags
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:59:34 +00:00
Claude
55884f81eb refactor(marmot): extract the incoming-sync policy from the CLI into commons
Context.syncIncoming carried real protocol policy — the NIP-59 gift-wrap
since-cursor rule (2-day lookback, advance only when events arrive),
per-group cursor bookkeeping, and the MIP-00 consumed-KeyPackage
rotation — that the Android app implements separately. Divergence there
silently drops DMs, so the policy now lives once in
commons/marmot/MarmotSyncPolicy with Cursors/Relays/drain/publish
injected, and the CLI Context wires itself in as a thin adapter.
Behavior is unchanged (the body moved verbatim, comments included).

Also resolves aliases from the per-account aliases.json in
Context.requireUserHex, so 'amy dm send bob ...' works with a local
alias — previously aliases.json was written but never read.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:45 +00:00
Claude
7b5d9a0fe4 feat(cli): contract sweep for notes/nsite/offer/podcast/relay/search/zap verbs
Completes the module-wide contract sweep (N-Z families):
- rejectUnknown() everywhere Args is constructed
- publishGuard on single-event publishes (post, publish, profile edit,
  nsite/napplet publish, podcast + podcast20, relaygroup join/leave/
  message/invite/edit/put-user/remove-user)
- USAGE constants + route(help=...) / --help fast-paths; nsite/napplet
  publish/serve/list, offer discover/pay and zap --with are now
  documented in-binary; subscribe's USAGE pins the NDJSON stream shape
- notes post parses flags before positionals (flags may appear anywhere)
- search note: --kind canonical (--kinds kept as alias); search user
  default limit 20 -> 50
- --identifier accepted as alias of --d in nsite/napplet/podcast20
- relay dispatch gains a full USAGE (--help) documenting every noun

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:58:19 +00:00
Claude
11901df2c6 feat(cli): contract sweep for event/git/geochat/graperank/group/key/login verbs
Same contract as the A-D sweep, applied to the E-M command families:
- rejectUnknown() everywhere Args is constructed (typo'd flags -> bad_args)
- publishGuard on single-event publishes (total rejection -> non-zero)
- USAGE constants + route(help=...) / --help fast-paths; graperank's
  full sub-verb set (including the previously undocumented 'followers')
  and 'key validate' + the --pw alias are now documented in-binary
- geochat --relay is a strict comma-list (bare positional relays kept);
  geochat listen default limit 500 -> 50
- graperank update/probe aliases now print deprecation notes
- git --identifier accepted as alias of --d
- marmot message list defaults to --limit 50 (0 = everything)
- dm-style guards for marmot message send/react/delete and group edits

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:23 +00:00
Claude
471883a9f2 feat(cli): contract sweep for admin/await/blossom/bunker/concord/dm/cashu verbs
Applies the new CLI contract across the A-D command families:
- rejectUnknown() after flag parsing, so typo'd flags fail with bad_args
  instead of silently no-oping
- publishGuard on single-event publishes: total relay rejection now
  exits non-zero ('rejected') instead of 0
- per-group USAGE constants wired into route(help=...) and --help
  fast-paths on flat commands; previously invisible sub-verbs
  (blossom media/report, the concord moderation set, the whole cashu
  surface, NIP-86 method list) are now documented in-binary
- dm send/send-file parse flags before positionals (flags may appear
  anywhere); dm list/await accept a positional USER as alternative to
  --peer; dm list caps at 50 by default
- concord create: --relay is canonical (--relays kept as alias)
- cashu receive resume: deprecation warning pointing at 'complete'
- offer/debit: --timeout is seconds (was raw milliseconds)
- Identity.fromBunkerUri now delegates to quartz NostrConnectURI
  parseBunker instead of duplicating the URI parsing

BREAKING (--json): unknown flags and malformed numeric/relay/author
flag values now exit 2 where they were previously ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:56:09 +00:00
Claude
6bcae39b83 fix(cli): repair the exit-code and flag-parsing contract
- Output.error now derives the exit code from the error code
  (bad_args -> 2, timeout -> 124, else 1), so every
  'return Output.error(...)' site honours the documented contract.
  Previously ~225 bad_args sites exited 1 while the docs promised 2,
  and two timeout paths (nostrconnect wait, namecoin lookup) exited 1
  instead of 124.
- Args: literal '--' ends flag parsing (escape hatch for values that
  start with '--'); intFlag/longFlag reject non-numeric values instead
  of silently using the default; requireFlag/positional no longer
  double-print to stderr; new rejectUnknown() turns typo'd flags into
  bad_args failures; new 'help' detection.
- route() understands --help/-h/help (prints group usage, exit 0) and
  names the expected verbs on an unknown sub-verb.
- Unknown or missing top-level subcommand now emits a proper bad_args
  error (JSON-aware under --json) plus a one-screen verb list instead
  of dumping the full 400-line usage.
- RawEventSupport: --relay/--kind/--author/--id/--since/--until/--limit
  entries that do not parse are now bad_args errors; previously an
  unresolvable --author was silently DROPPED and the query ran with a
  weaker filter than requested. New shared publishGuard() reports
  'rejected' (exit 1) when every relay refuses an event.
- runCli() seam extracted from main() plus an 'amy.home' system-property
  override of DEFAULT_ROOT so the new JVM test suite can drive the CLI
  in-process; first contract tests: ArgsTest, ExitCodeContractTest,
  JsonContractTest (NIP-19 vector goldens).

BREAKING (--json): error code pow_timeout is now timeout; exit codes
for bad-argument failures move from 1 to 2 as documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP4kfLCa3wWtE8Khy21Pkj
2026-07-18 20:54:14 +00:00
Vitor Pamplona
7e3c01ab08 Merge pull request #3637 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 16:18:38 -04:00
Claude
b852a163c7 feat: paginate the NIP-29 group Threads tab with a backward history pager
The Threads tab loaded kind-11/1111 with a single #h since-filter and no
limit, so a group with more threads than the relay's default result cap
silently lost the older ones. Mirror the chat history stack for threads:

- RelayGroupChannel.threadsHistory: separate RelayLoadingCursors so paging
  the forum doesn't move the chat's cursor.
- buildRelayGroupThreadsHistoryFilters: per-armed-relay #h + kind-11/1111
  until+limit page, the forum analog of buildRelayGroupHistoryFilters.
- RelayGroupOpenThreadsHistoryFilterAssembler: the on-demand BackwardRelayPager
  ("relayGroup.threads.history"), bound to the open group's threadsHistory
  cursors, landing on the normal ingest path (kind-11 -> addThread).
- Threads screen: mount the history subscription, eagerly backfill to a
  window on open, page older content as the list nears its end, and show a
  quiet loading/caught-up footer.

Tests: RelayGroupFilterBuildersTest gains the threads-history filter shape;
RelayGroupFilterServingRelayTest gains a geode backward #h + thread-kinds
walk proving every thread is covered exactly once and the walk terminates.

The screen wiring is device-untested (flagged with the other Tier-D items).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:54:18 +00:00
Claude
3214095b99 fix: route stray NIP-29 group content to the host, not a phantom channel
A group-scoped content event (kind-9 chat, poll, kind-11 thread) is keyed
to its RelayGroupChannel by the relay that served it, because a NIP-29
event doesn't carry its host relay. That's correct for the group's own
host-pinned subscriptions, but a message resolved from a NON-host relay --
e.g. a quoted kind-9 fetched by id during missing-event resolution -- was
filed under GroupId(groupId, strangerRelay), a channel the group's screens
never read, so the message silently vanished (the serving-relay hazard).

LocalCache.attachToRelayGroupIfScoped / attachThreadToRelayGroupIfScoped
now, when no channel is keyed to the serving relay, redirect the stray to
the group's single confirmed host channel via redirectStrayRelayGroupContent,
keyed off RelayGroupChannel.hasRelaySignedState(). A phantom channel never
has relay-signed state, so the redirect can only ever land on a real host,
never on another phantom -- the fix is strictly safe and the common
host-pinned arrival stays an untouched O(1) fast path (the scan runs only
on the rare no-channel-for-serving-relay miss).

Also add the cache-prune gap-fill can't-miss test: drive the production
RelayLoadingCursors down a real relay, rewindTo below the window, and
confirm the pruned band re-loads with no gap.

Tests: RelayGroupContentRoutingTest (pure router + the channel signal);
RelayGroupHistoryPagingRelayTest gains the rewind reload case. The
LocalCache wiring is unit-covered at the router level but still device-
untested end-to-end (flagged in the test plan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 18:13:31 +00:00
vitorpamplona
c5f10c7518 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 18:06:39 +00:00
Vitor Pamplona
0d534a3149 Merge pull request #3638 from vitorpamplona/claude/post-image-render-shift-b4vx28
Fix media detection for malformed imeta MIME types
2026-07-18 14:04:00 -04:00
Claude
db5170a82e test: extend NIP-29 group-chat coverage to every assembler + relay integration
Round out the branch's test plan across the headless-runnable tiers.

Tier B (filter shapes) — now every assembler:
- reconnect stability: a since-only bump on the state/tail filters is not a
  resend (no full replay on reconnect), while a history until step is; via
  FiltersChanged.needsToResendRequest.
- directory: extract buildRelayGroupDirectoryFilter into RelayGroupFilterBuilders
  (kinds 39000-39003, no d/h scope, limit 500) and point the RelayGroupsOnRelay
  assembler at it, with a shape test.
- ChannelPublic relay-group branch (filterRelayGroupState): state + pinned-id
  backfill and crucially NO message window.
- group notifications (filterGroupNotificationsToPubkey): #p+#h scope, kind set,
  empty-guards.
- discovery #p roster augmentation (filterRelayGroupsByAuthors): the author,
  #p-roster and #d-backfill filter shapes.

Tier C (serves-the-shape, against the in-process geode relay): state #d,
batched preview tail, threads, a pinned message reachable by id below the tail
window, notification #p+#h scoping, and the relay directory.

Tier C3/E1 (can't-miss + resilience): drive the production RelayLoadingCursors
backward over the wire to the bottom, and pin that a short page is not
exhaustion (only an empty page + EOSE ends the walk).

The remaining hostile-relay faults (echo-newest, no-EOSE, auth/stall) are
already covered generically by RelayLoadingCursorsTest, WindowLoadTrackerIdleTest
and BackwardRelayPagerTest; Tier D (device) and E2 (real third-party relay) are
not headless-runnable and stay flagged for a human in the test plan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:54:38 +00:00
Claude
4653951b54 fix: recover media type from file extension when imeta MIME is malformed
Primal iOS writes a bare subtype in the imeta MIME tag (`m jpeg`) instead of a
full type (`m image/jpeg`). `createMediaContent` classified media only by the
MIME `startsWith` prefixes when a MIME was present, so a bare subtype matched
neither image/video/pdf and the whole imeta was dropped: the URL rendered as a
plain link. That path is doubly bad — it discards the imeta `dim`/blurhash, so
the loading placeholder cannot reserve the image's height and the feed jumps
when the bitmap finally arrives, and it forces a URL-preview network round-trip
just to rediscover the type the imeta already declared.

Fall back to file-extension detection whenever the type is still unknown after
the MIME/data: checks. This recovers the `.jpg` (or `.mp4`, …) classification
and keeps the imeta metadata, so the image renders through the fast media path
with its dimensions reserved up front. `data:` URIs keep their type in the
prefix and are left unprobed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQ9F7xnRvRgBmVNbo8hRKc
2026-07-18 17:40:31 +00:00
Claude
399bef6bf7 test: cover NIP-29 group-chat filter shapes and can't-miss history paging
Extract the pure REQ-filter construction out of the NIP-29 group-chat
assemblers into RelayGroupFilterBuilders so the exact filter each screen
puts on the wire can be unit-tested without an Account or relay client,
and point every assembler at the shared builders (dropping the duplicated
per-file kind lists).

Add two test suites from the branch's test plan:

- RelayGroupFilterBuildersTest (Tier B): pins the kinds, #d/#h scope,
  per-host-relay batching, since/until/limit and all-authors shape of the
  state, joined chat-tail, open chat-tail, history-pager, threads and
  card-warmup joined-skip filters.

- RelayGroupHistoryPagingRelayTest (Tier C3/E1): the can't-miss-messages
  property against the in-process geode relay -- a backward #h + kind-9
  walk delivers every group message exactly once and stops on an empty
  page, and an #h-scoped walk isolates one group from another on the same
  relay even when their createdAt ranges fully overlap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 17:13:56 +00:00
Vitor Pamplona
8ba0d0c317 Merge pull request #3633 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-18 12:57:53 -04:00
Vitor Pamplona
854c9907e2 Merge pull request #3636 from vitorpamplona/claude/strictmode-disk-read-violation-2s5q8e
Fix StrictMode DiskReadViolation in BootCompletedReceiver
2026-07-18 12:57:47 -04:00
Claude
4cf86ca030 docs(nip29): add Tier E — relay-behavior resilience + third-party conformance
The friendly-geode tiers test the client against a compliant relay; they neither
harden it against buggy relays nor surface bugs in the third-party NIP-29 relays
users connect to. Tier E adds both:

- E1: a deterministic fault-injecting mock relay (ignore since/until, short pages,
  echo-newest, out-of-order, EOSE-early/none, AUTH-CLOSE, mid-stream drop, caps)
  asserting the tail/pager still converge with no miss / loop / hang. Notes that
  UntilLimitPagingRelayTest + BackwardRelayPagerTest already cover several cases
  for the generic pager, to be extended to the NIP-29 #h shapes.
- E2: a conformance run against a real containerized NIP-29 relay via relayBench's
  RelayUnderTest adapter (AUTH gating, relay-signed 39xxx, previous-tag rejection,
  since/until semantics + caps) — a failure is a relay bug, doubling as a
  conformance report for the operator.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 16:04:07 +00:00
Claude
c8362fbc18 docs(nip29): per-screen × per-assembler test plan for the group-chat refactor
A validation plan an AI (or human) on this branch runs before trusting the
state-vs-content refactor. Organized by screen and covering every NIP-29
assembler (JoinedState, JoinedChatTail, OpenChatTail, OpenChatHistory,
OpenThreads, CardWarmup, RelayGroupsOnRelay, RelayGroupsDiscovery, the
ChannelPublic relay-group metadata branch, and the notifications path).

Tiers: (A) build + JVM suite; (B) new amethyst unit tests for each assembler's
filter shape + reconnect stability; (C) amy + geode/`amy serve` integration for
the framework, ingest and the can't-miss-messages paging property (C3); (D)
Android per-screen scenarios with logcat pass criteria, including the original
join-mid-session bug (D1) and reconnect no-replay (D8). Flags what is verifiable
headless vs. needs a device, and the known-failing serving-relay hazard row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 15:16:36 +00:00
Claude
20e86b8998 fix: move BootCompletedReceiver disk read off the main thread
isEnabled() reads plain SharedPreferences (a synchronous disk read, by
design) and scans the accounts cache. Since onReceive runs on the main
thread, this triggered a StrictMode DiskReadViolation at boot. Wrap the
work in goAsync() + a background thread so it runs off the main thread
while keeping the receiver alive until the check completes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P4tx1QSE27uoDpnxZZ7msT
2026-07-18 14:39:03 +00:00
Claude
ee5dc25414 refactor(nip29): finish the datasource rename — Open threads + consistent filter-builder names
Continues the scope+role naming pass over the group-chat datasource layer:

- RelayGroupThreadFeed -> RelayGroupOpenThreads: it's the open group's threads
  tab, so it now sits in the "Open" family beside RelayGroupOpenChatTail/History.
- filterMetadataToRelayGroup -> filterRelayGroupState: matches the "state"
  concept (39000-39005 + pinned-id back-fill) the always-on state sub is named for.
- makeRelayGroupsDiscoveryFilter -> filterRelayGroupsDiscovery: consistent
  `filter…` verb with the rest of the relay-group filter builders.

The RelayGroupsOnRelay / RelayGroupsDiscovery assemblers keep their PLURAL prefix
on purpose: it marks the "browse/discover many groups" surfaces apart from the
singular in-a-specific-group ones (RelayGroupJoined*/Open*/Card*). The already-
consistent filterRelayGroupsBy* discovery builders are left as-is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 14:33:56 +00:00
vitorpamplona
9fb3603ed6 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 14:24:00 +00:00
Vitor Pamplona
2466132fb7 Merge pull request #3635 from vitorpamplona/claude/amethyst-blossom-client-p35sql
Add Blossom blob manager UI and BUD-07 payment handling
2026-07-18 10:21:08 -04:00
Claude
54b2b42d87 refactor(nip29): scope Card warmup to non-joined, rename the group-chat assemblers by scope+role
Two clarity cleanups on the group-chat subscription split:

1. RelayGroupCardWarmup (ex-Warmup) now skips a group the user has already joined.
   Joined groups are kept fully warm app-wide by the always-on state + chat-tail
   subs; warmup only needs to cover groups shown as cards that those don't — above
   all NON-joined groups (discovery, a relay's channel list, member/metadata/parent
   screens). Removes the last joined-group double-fetch. (A joined group appearing
   in the discovery "My Groups" tab now draws its card activity from the recent-tail
   cache instead of a fixed newest-50 fetch.)

2. Rename the assemblers so their scope and role read at a glance, side by side:
     RelayGroupState      -> RelayGroupJoinedState     (joined · state, always-on)
     RelayGroupPreviewTail -> RelayGroupJoinedChatTail (joined · recent chat, always-on)
     RelayGroupChatTail   -> RelayGroupOpenChatTail    (open group · recent chat)
     RelayGroupChatHistory -> RelayGroupOpenChatHistory (open group · older chat)
     RelayGroupWarmup     -> RelayGroupCardWarmup      (on-screen card, non-joined)
   The family now reads Joined{State,ChatTail} · Open{ChatTail,ChatHistory} · CardWarmup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 13:57:44 +00:00
Claude
a25c5f9518 fix: harden Blossom sync concurrency and cancellation
Audit fixes for the Blossom client:

- BlossomBlobManagerViewModel: use StateFlow.update{} for the presence
  matrix so the Main-thread sync collector and IO-thread delete/mirror
  actions can't lose each other's writes; add refreshJob de-dup so two
  quick refreshes can't interleave; rethrow CancellationException; bound
  the /list HEAD-probe backfill with a Semaphore(8).
- BlossomClient.has(): rethrow CancellationException instead of
  swallowing it as 'not found'.
- BlossomSyncForegroundService: drop the stale 'running' de-dup guard so
  a fresh sweep always gets foreground protection.
- CLI mirror: strip query/fragment before extracting the sha256.
- DisplayBlossomSyncProgress: retain the last state so the slide-out exit
  animation still has content to draw after the state clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 13:46:06 +00:00
Claude
84640a5890 perf(blossom): parallelize "sync all" across servers
The sweep was strictly sequential (one mirror at a time). Now it parallelizes
ACROSS servers but stays serial WITHIN each server, so every server gets a steady
one-at-a-time stream (no rate-limit storms) while all of the user's servers work at
once — wall-clock drops to roughly the slowest single server's queue instead of the
sum of everything.

- BlossomMirrorQueue: group work by target server, run one coroutine per server, and
  update the shared progress atomically via MutableStateFlow.update from the parallel
  workers. Extracted mirrorOne() for the per-task call.
- Dropped the now-ambiguous currentHost from BlossomSyncState (and the banner /
  notification text) since multiple servers are in flight at once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 13:31:36 +00:00
Vitor Pamplona
bc3c3460c0 Merge pull request #3634 from davotoula/fix/graperank-flag-constants
Extract GrapeRank CLI flag names into constants
2026-07-18 08:11:37 -04:00
davotoula
ab6682d521 refactor(cli): extract GrapeRank CLI flag names into constants 2026-07-18 11:00:41 +01:00
davotoula
bb75ebf87c update cz, se, pt, de in amethyst 2026-07-18 10:16:46 +01:00
davotoula
314c5e41f2 update cz, se, pt, de in commons 2026-07-18 10:16:35 +01:00
davotoula
2f0c178eab Update translation skill for new commons strings 2026-07-18 10:16:18 +01:00
Claude
5d54a0ff90 refactor(fgs): extract FlowProgressForegroundService; add dataSync Blossom sync service
Generalizes the shared foreground-service scaffolding (channel setup, start/stop-
when-idle over a StateFlow, the ProgressStyle notification skeleton, tap + cancel
intents, onTimeout) into an abstract FlowProgressForegroundService<T>. Kept as a
base — NOT one mega-service — because Android 14+ binds foregroundServiceType to
behavior, so each workload keeps its own subclass with the correct type.

- FlowProgressForegroundService: the shared base.
- PowMiningForegroundService: refactored onto the base (still shortService); mining
  state maps to the card via render(); behavior preserved.
- BlossomSyncForegroundService: new, typed dataSync (the right type for a multi-file
  sync that exceeds shortService's ~3-min budget), bound to blossomMirrorQueue.state.
  Started (from the foreground, as dataSync requires) when a "Sync all" sweep begins,
  so it survives backgrounding; stops itself when done.
- BlossomMirrorQueue: publishes the active state and fires onActive() synchronously so
  the FGS starts with live state; wired in AppModules.
- Manifest: FOREGROUND_SERVICE_DATA_SYNC permission + the service declaration.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 04:33:10 +00:00
Claude
050ecaaf9f feat(nip29): split group chat into always-on state + live tail + history pager
Consolidates the six overlapping NIP-29 group-chat REQ assemblers into the
state-vs-content shape the DM and Concord chat stacks already use, reusing the
BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker framework. See
amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md.

State (always-on, account-keyed), mounted at LoggedInPage:
- RelayGroupStateFilterAssembler — one #d filter per host relay carrying every
  joined group's 39000/39001/39002/39003/39005. Keeps name/roster/roles/pins
  current app-wide, so no screen re-queries metadata. Promotes (and replaces) the
  old RelayGroupMyJoinedGroups roster path from "while a groups screen is up" to
  genuinely always-on.
- RelayGroupPreviewTailFilterAssembler — one #h filter per host relay across all
  joined group ids, since=recentBoundary(), NO per-group limit (a time floor
  batches it and makes it reconnect-safe). Drives the Messages-list previews.
  Replaces the old fixed-window content path whose shared per-relay `since` gated
  a newly-joined group's backfill (the reported slow-first-load bug).

Content (per open group), mounted on the chat screen:
- RelayGroupChatTailFilterAssembler — the open group's recent chat live (covers a
  non-joined group opened by link, which the joined-only preview tail misses).
- RelayGroupChatHistoryFilterAssembler — on-demand backward pager (until+limit on
  the host relay, all authors), cursors on RelayGroupChannel.history, driven by
  the feed's viewport markers with an eager backfill-to-window on open. Gap-proof
  via RelayLoadingCursors.rewindTo, so deep scroll never misses older messages —
  and being all-authors it re-materializes my own history too.

Retires the duplicated paths:
- RelayGroupMyJoinedGroups{FilterAssembler,Subscription} deleted (state + preview
  subs cover it); unmounted from Messages panes, discovery, bottom-bar preloader.
- ChannelPublicFilterSubAssembler RelayGroupChannel branch drops the content
  window, keeps only metadata + pinned-id backfill (for a non-joined open group).
- ChannelFromUserFilterSubAssembler RelayGroupChannel branch removed (redundant).
- FilterMessagesToRelayGroup / FilterMyMessagesToRelayGroup deleted (orphaned).

Warmup (non-joined cards), OnRelay (directory), Discovery, ThreadFeed and the
always-on notifications path are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 04:19:50 +00:00
Claude
af9d5a7278 docs(nip29): plan to split group chat into always-on state + paginated content
Design doc for consolidating the six overlapping NIP-29 group-chat REQ assemblers
into the same state-vs-content shape the DM and Concord chat stacks already use:
an always-on account subscription for the small replaceable state (metadata /
roster / roles / pins), and a live tail + on-demand backward history pager
(reusing BackwardRelayPager / RelayLoadingCursors / WindowLoadTracker) for the
high-volume chat content. Includes the full message-delivery coverage proof
(can't-miss-messages), the file-by-file change list, and the additive-first
rollout order.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 04:19:25 +00:00
Claude
4845e95c9f chore(blossom): rename "My Blossom Data" to "My Blossom Files"
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:59:37 +00:00
Claude
ecbe2c6704 feat(blossom): app-level "Sync all" with floating progress; rename to "My Blossom Data"
Sync-all now runs like the PoW miner — an app-level job with a floating progress
banner — instead of a screen-bound loop, so it keeps going as the user navigates:

- BlossomMirrorQueue (app-level, on applicationIOScope via Amethyst.instance): runs
  the BUD-04 fan-out, exposes an aggregate BlossomSyncState for the banner and a
  results SharedFlow so an open manager flips each pill green/grey live. Servers that
  need payment are skipped (pay those per-row).
- DisplayBlossomSyncProgress: a bottom floating banner mounted at the navigation root
  (sibling of the mining/broadcast banners) with a determinate bar, "x / N · host",
  failed count, and cancel/dismiss.
- Manager: a "Sync all" banner appears when any file has gaps; tapping it enqueues the
  sweep and optimistically spins the affected pills.

Also, per request: the screen is renamed "My Blossom Data" and moved into the left
drawer's "You" section (just before My Emoji Packs); the standalone Settings-catalog
entry is removed and the Media Servers shortcut relabeled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:57:58 +00:00
Claude
da633342bc feat(blossom): faster, more legible blob manager (load, progress, refresh)
Addresses three UX gaps on the "Manage stored files" screen:

- Loading was a full-screen spinner until every server × every blob HEAD probe
  finished, sequentially — and it even HEAD-probed servers that had already
  returned a /list. Now: /list runs across all servers in parallel and renders
  immediately; only servers that don't implement /list are HEAD-probed, and those
  run in parallel too. A server that listed its blobs needs no probe. Result is
  shown as soon as the lists land.

- Mirror-to-missing gave no feedback. Presence is now a per-server state
  (present/missing/pending); the target pill shows a spinner while mirroring and
  turns green on success or back to grey on failure — updated in place, no full
  reload. Delete spins the pill then greys it (and drops the row when it's gone
  everywhere).

- The refresh control was a FAB mislabeled "Retry". It's now a proper refresh
  action in the top bar (a spinner while loading); the contextual "Retry" stays
  only on the error state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:34:42 +00:00
Claude
6920dc6e82 fix(blossom): don't let mirroring corrupt upload progress or fail the upload
Two regressions from the mirror-on-upload change:

1. Progress state stuck. verifyHeader() ends by calling finish() (state=Finished,
   progress=1.0), but mirrorToOtherServers then ran updateState(0.95,
   ServerProcessing) and never restored Finished — so the on-screen progress ended
   pinned at "processing / 95%" and isUploading never cleared. Mirroring is now
   fire-and-forget on account.scope AFTER the upload finishes: it never touches the
   progress state, never delays the post, and can never turn a completed upload into
   a failure.

2. Upload could fail outright. The upload token was BUD-11 server-scoped; some
   servers reject an upload whose auth carries a `server` tag, surfacing as a bare
   "Uploading error:" with no detail. Upload-token replay isn't the threat scoping
   guards against (delete tokens are — those stay scoped), so upload/media tokens
   are no longer scoped. Also: blank exception messages now fall back to the class
   name so the dialog is never empty.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 03:05:37 +00:00
Claude
22396cf583 feat(blossom): redesign blob manager to match the app's modern UI
Brings the "Manage stored files" screen up to the design language main just
applied to the Media Servers screen:

- Rounded surfaceContainer cards instead of flat Cards.
- Image thumbnail, or a file-type glyph in a rounded secondaryContainer box
  for non-images.
- Monospace, truncated-both-ends hash; type · size subtitle.
- Per-server presence as pills with a status dot — allGoodColor tint for
  present, muted for missing — replacing the ambiguous same-shaped chips.
- A single MoreVert overflow (copy link / open / report / per-server delete)
  with leading icons, plus a full-width tonal "Mirror to missing" CTA.
- Icon-in-rounded-box empty/error states and an ExtendedFloatingActionButton
  refresh.
- BUD-07 pay dialog restored with a Bolt icon and tonal confirm; payment
  message now names the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 02:32:51 +00:00
Claude
e87309e038 test(blossom): live interop smoke test for amy blossom (BUD-01/02/04/09)
Adds cli/tests/blossom/blossom-live.sh, an end-to-end harness that drives the
full amy blossom lifecycle against a REAL public Blossom server: upload → HEAD
check → download-and-verify-hash → list → cross-server mirror → delete. Verified
green (6/6) against files.sovbit.host with a mirror to blossom.primal.net, which
exercises the whole quartz/commons/CLI Blossom stack over the wire.

Server-side write rejections (whitelists, rate limits, payment) record as SKIP,
not FAIL — only a broken client contract (bad descriptor, hash mismatch,
unparseable list) fails — since public servers gate anonymous writes very
differently. Documented in cli/tests/README.md; state dir gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 02:16:17 +00:00
Vitor Pamplona
4e53f26b38 Merge pull request #3630 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 22:10:07 -04:00
Claude
7dcf2afa0f perf(chats): make ChannelNewMessageViewModel.init idempotent across recompositions
Every channel composer screen (NIP-28 public chat, NIP-29 relay group, NIP-53
live activity, ephemeral and geohash chats) calls `init(accountViewModel)`
directly from its composable body, so it ran on the main thread on every
recomposition of that body — re-allocating UserSuggestionState,
EmojiSuggestionState and a fresh ChatFileUploadState each time. Besides the
wasted main-thread allocations, blindly re-initializing `uploadState` would
reset an in-progress upload.

Guard the setup so it only (re)runs when the account actually changes, matching
the pattern the sibling ConcordNewMessageViewModel already uses. Repeated calls
with the same account are now cheap no-ops, so leaving the call in the composable
body stays correct while dropping the per-recomposition work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 01:25:52 +00:00
Claude
9d8e07fbee perf(nip29): prefetch a joined group's history with a per-group since
The Messages-list warmup (RelayGroupMyJoinedGroupsSubAssembler) is keyed by
account, so its `since` is one per-relay EOSE map shared across every joined
group on that relay — not per-group. Feeding that shared `since` into the
bounded per-group content backfill gated any group joined (or first surfaced)
after that relay's `since` advanced — or after a live message on a sibling group
pushed it forward: the group only ever fetched events newer than that timestamp,
so its history never landed in the Channel's (strong-ref) notes cache. The
Messages row showed the newest message while opening the group waited on a full
`limit=200` relay round-trip — "the last message is there but the group loads
slowly even though it should be cached."

Dropping `since` entirely would be just as wrong: the pool re-sends every REQ on
each reconnect (FiltersChanged treats a brand-new connection's empty prior state
as a change), and reconnects happen constantly, so a since-less filter would
replay the whole newest-50 page for every group on every reconnect.

Instead, gate the shared `since` per group on whether we already hold a full page
(>= LIMIT) of that group's preview content in the notes cache:
  - < LIMIT cached  -> cold / newly joined / thinly scattered: fetch the full
                       page with no `since`; once it lands it flips to incremental
                       on its own.
  - >= LIMIT cached -> already backfilled: apply the shared `since` so reconnects
                       fetch only the tail (the group already has its history, so
                       sharing the relay `since` only bounds incremental top-ups).
A group with genuinely fewer than LIMIT total events re-pulls its (sub-page,
cheap) content on reconnect — an acceptable cost for guaranteeing the backfill.

Roster stays on the shared per-relay `since`: those are a few small replaceable
events per group, so a reconnect just re-confirms them rather than replaying chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDK63toGbE7DQxKxrQnhMU
2026-07-18 01:25:28 +00:00
Claude
9bc436b0c2 feat(blossom): BUD-07 confirm-then-pay for paid-server mirroring
Adds a confirm-then-pay flow so a 402 from a paid Blossom server can be settled
from the app instead of only being reported.

- quartz: BlossomPaymentProof (settled Cashu token / lightning preimage) with the
  X-Cashu / X-Lightning retry headers; BlossomClient.mirror accepts a proof.
- BlossomPaymentHandler (Android): pays the challenge's BOLT-11 invoice via the
  account's existing NIP-47 (NWC) wallet and returns the preimage — it never
  handles keys or funds itself, only drives the connected wallet. Decodes the
  invoice amount for display.
- Blob manager: a mirror that hits 402 now raises a payment prompt; a dialog shows
  the amount and, on confirm, pays and retries the mirror, then continues with the
  remaining servers. Cancel leaves the blob unmirrored.

Cashu-only servers and the composer upload path still surface a clear message;
auto-settlement there can reuse this handler next. Not yet validated against a
live paid server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 01:18:46 +00:00
vitorpamplona
69a5b3be00 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-18 01:09:34 +00:00
Vitor Pamplona
2cfac578fe Merge pull request #3631 from vitorpamplona/claude/concord-nip29-message-indicators-jwyvgt
Add unread indicators for NIP-29 relay groups and Concord channels
2026-07-17 21:06:55 -04:00
Vitor Pamplona
67e02618e2 Merge pull request #3632 from vitorpamplona/claude/settings-search-placement-n1wkqw
Move settings search field to top bar
2026-07-17 21:04:37 -04:00
Claude
4f661a501f feat(blossom): blob-manager per-file controls + clear 402 on upload
- Blob manager rows now show an image thumbnail (image/* blobs) and Copy-link /
  Open controls, alongside the existing mirror-to-missing / delete / report.
- BUD-07: BlossomUploader now raises a typed BlossomPaymentException on a 402, and
  UploadOrchestrator surfaces a specific "server requires payment" message instead
  of a generic upload failure (auto-settlement via the NIP-60 wallet is still TBD).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 01:04:35 +00:00
Claude
5ad3e143e4 fix: keep Concord community unread flow alive across Refounding + dedupe emissions
Audit follow-ups on the Messages unread dot:

- concordCommunityHasUnreadFlow captured the community session once, but a
  Refounding rebuilds a still-joined community's session in place (same id, new
  object). Because the row's flow is remember(communityId)-scoped it was never
  recreated, so the fan-in kept watching the dead session and the dot froze.
  Re-resolve the session on every concordSessions.revision tick instead — the
  same signal ConcordServerRoomCompose already uses to refresh the row's
  name/icon — which also covers channel-set changes from folds.
- distinctUntilChanged() on both aggregated server flows so a new message that
  doesn't flip the has-unread boolean no longer pushes a redundant value into
  composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
2026-07-18 00:55:30 +00:00
Claude
6c01ad2b10 feat(blossom): mobile upload controls — mirror + /media toggles, manager refresh
Adds first-class Blossom upload controls to the Media Servers screen and rounds
out the blob manager:

- New persisted account settings (LocalPreferences + AccountSettings):
  `mirrorUploadsToAllServers` (BUD-04, default on) and `optimizeMediaOnUpload`
  (BUD-05, default off), each with a change fn.
- UploadOrchestrator honours both: uploads via /media with a t=media token when
  optimize is on, and only mirrors when the mirror toggle is on.
- BlossomUploader gains a useMediaEndpoint flag to target /media vs /upload.
- "Upload behaviour" section on the Media Servers screen: mirror toggle,
  optimize-via-/media toggle, and a shortcut into the blob manager.
- Blob manager gains a refresh FAB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-18 00:07:30 +00:00
Claude
d0f2f08e19 fix(blossom): mirror and blob-manager use only the user's configured servers
Both the mirror-on-upload fan-out and the blob-manager matrix read the raw
kind-10063 list (BlossomServerListState.flow) instead of hostNameFlow, which
injects the 10 public DEFAULT_MEDIA_SERVERS when the user has none set. This
avoids fanning uploads out to — and listing/HEAD-probing across — public
servers the user never opted into.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-17 23:50:53 +00:00
Claude
ed4b851518 feat(settings): host the settings filter in the app bar
Move the All Settings search field from a separate row below the top bar
into the app bar itself, replacing the static "Settings" title. The back
arrow is still shown only when the back stack can pop (deep-link entry),
so bottom-nav entries stay chromeless.

Adds SettingsSearchTopBar with a compact pill search field sized to fit
the app bar and leaves the shared TopBarWithBackButton untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBSLyPYssVu8QdBrvCM7wk
2026-07-17 23:50:25 +00:00
Vitor Pamplona
3e9da48c8a Merge pull request #3629 from vitorpamplona/claude/crowdin-ci-fix-lzm3ac
fix(ci): reclaim working-tree ownership after Crowdin Docker action
2026-07-17 19:45:47 -04:00
Claude
91765c56aa fix(ci): reclaim working-tree ownership after Crowdin Docker action
The crowdin/github-action runs inside a Docker container as root, so files
and directories it downloads are root-owned. When Crowdin creates a brand-new
locale folder (e.g. values-en-rGB/ under commons), the unprivileged runner
user in the peter-evans/create-pull-request step can't unlink files inside it,
so the branch checkout aborts with 'unable to unlink ... Permission denied'.

Add a chown step that reclaims ownership of the whole working tree after the
Crowdin action and before any git manipulation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0179E3AfsnB1NP87YmtAMZNX
2026-07-17 23:39:44 +00:00
Claude
bb03cd2a3c feat(blossom): full-client protocol support across quartz, commons, CLI and Android
Extends Blossom support toward a full client on both the CLI and the mobile app.

Quartz (protocol):
- BlossomAuthorizationEvent: add t=media auth (BUD-05) and optional BUD-11
  `server` domain scoping on every factory (stops replayable upload/delete tokens)
- BlossomServerUrl: mirror/media/list/report path builders, BUD-06 preflight and
  BUD-07 payment header constants, and a lowercase bare-domain helper
- BlossomUploadResult: parse `ox` (BUD-05 original hash) and `nip94` (BUD-08)
- BlossomPaymentRequired: BUD-07 402 challenge model (Cashu/Lightning)
- BlossomReport: BUD-09 kind-1984 blob report reusing NIP-56 tag builders

Commons (shared JVM client, now in jvmAndroid so Android shares it too):
- BlossomClient gains mirror (BUD-04), list/delete (BUD-02), media (BUD-05),
  preflight/has (BUD-06/01), report (BUD-09) and typed 402 handling
- BlossomAuth: media/list/delete passthroughs with server scoping

CLI (first-class):
- amy blossom now routes all HTTP through the shared client and adds `media`
  and `report` verbs; auth tokens are scoped to --server

Android (first-class):
- uploads mirror to the user's other Blossom servers (BUD-04) best-effort
- new "Manage stored files" screen: per-server presence matrix (BUD-02 list +
  BUD-01 HEAD), delete, mirror-to-missing, and report actions

Tests: quartz URL/auth/descriptor/payment parsing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ckbnz1N94W1hnNC9xpsCNP
2026-07-17 23:36:41 +00:00
Claude
26b24c9125 feat: show unread dot for NIP-29 groups and Concord communities in Messages
The Messages list showed the purple "new messages" dot (NewItemsBubble) for
DMs, NIP-28 public chats, ephemeral chats, geohash chats and Marmot groups,
but hardcoded hasNewMessages = false for the four NIP-29 / Concord rows, so
those never indicated unread activity.

Wire real unread detection into all four, reusing the last-read markers their
open-chat screens already advance:

- Individual NIP-29 group row and Concord channel row compare the newest
  message against the persisted last-read timestamp, exactly like the Marmot /
  public-chat rows do.
- The collapsed "grouped by relay" (RelayGroupServerRoomNote) and "grouped by
  community" (ConcordServerRoomNote) rows light the dot when ANY child group /
  channel has unread, via new reactive fan-in flows that follow the joined list
  / community session so joins and folds re-subscribe.

Extract the relay-group last-read route into relayGroupChannelLastReadRoute so
the read side (row) and write side (RelayGroupChannelView) can't drift, mirroring
marmotGroupLastReadRoute / concordChannelLastReadRoute.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014ZkjdHANMKyu2EbM7eEM8C
2026-07-17 23:33:18 +00:00
Vitor Pamplona
cd5060e5dc Merge pull request #3626 from vitorpamplona/claude/graperank-algorithm-improvements-oco5ue
Add follower crawl and trusted-follower metrics to GrapeRank
2026-07-17 19:29:49 -04:00
Vitor Pamplona
bdbf12f8be Merge pull request #3628 from vitorpamplona/claude/mediaservers-settings-ui-design-gbq9m7
Redesign media servers UI with drag-reorder, health probes, and cards
2026-07-17 19:28:01 -04:00
Claude
699c2dc7e5 fix(graperank): import kotlinx.coroutines.IO for Kotlin/Native compile
FollowerCrawler is in commonMain but used `Dispatchers.IO` without importing
the multiplatform `kotlinx.coroutines.IO` extension. On JVM `Dispatchers.IO`
resolves to the JVM member (compiled fine locally), but on Kotlin/Native that
member is internal, so `:quartz:compileKotlinIosSimulatorArm64` failed with
"Cannot access 'val IO': it is internal". Add the explicit import — the same
idiom the sibling GrapeRankCrawler already uses across all targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 23:26:46 +00:00
Claude
d2e18cb18d fix(media-servers): trim the gap above the first section header
The first "Upload priority" header stacked FeedPadding's top inset, an
extra 8dp, and SectionLabel's 20dp inter-section top padding — ~38dp of
dead space under the top bar. Make SectionLabel's top padding a parameter
and shrink it for the first section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 23:26:34 +00:00
Claude
a99fc59de8 feat(media-servers): auto-save on every change; polish cache card & add flow
Addresses review feedback on the redesign:

- Auto-save: drop the Save button and replace the cancel "X" with a
  standard back arrow (TopBarWithBackButton). Every add/remove persists
  immediately; a reorder persists once the drag gesture ends, so dragging
  no longer publishes a kind-10063 event on every intermediate swap.
- On-device cache card now uses the app's filled surfaceContainer card
  style (no outline), matching the main Settings screens.
- Drop the redundant intro sentence at the top of the screen.
- Rework the "Add a server" block: recommended servers come first as
  one-tap chips, followed by the "or paste a server address" field.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 23:21:02 +00:00
Vitor Pamplona
0ac31a7919 Merge pull request #3627 from vitorpamplona/claude/commons-crowdin-ci-acdrjg
chore: include commons Compose resources in Crowdin sync
2026-07-17 19:20:24 -04:00
Claude
7f99cbc3d6 fix(ci): include commons translations in the Crowdin PR
The Crowdin action now downloads the commons module's Compose-resource
translations too, but the create-pull-request step's add-paths still only
covered amethyst/ and docs/. New untracked commons files created by the
Crowdin action (run as root in its container) were therefore left out of
the commit and shelved by git stash --include-untracked, which failed with
'Permission denied' when trying to remove the root-owned files.

Add commons/src/commonMain/composeResources/**/strings.xml to add-paths so
those translations are committed into the PR instead of being stashed.
2026-07-17 23:14:36 +00:00
Vitor Pamplona
451e27e38d Merge pull request #3625 from vitorpamplona/claude/event-renderers-commons-7pttym
refactor(commons): extract note-type UI rendering to commons
2026-07-17 19:04:41 -04:00
Claude
4d199ab28a fix(media-servers): probe /upload not root; polish rows into cards
Health probe was hitting the bare server root with HEAD. CDN-fronted hosts
like cdn.satellite.earth never answer `/` (verified: HEAD/GET `/` time out,
while HEAD `/upload` returns 404 in ~0.5s), so a fast, working server was
wrongly shown Offline. Probe the BUD-01 `/upload` endpoint instead — the
path that actually matters for an upload target.

Visual polish:
- Server rows are now outlined cards; the primary target (#1) gets an accent
  border, a faint accent tint, and a "Primary" badge.
- Rank and monogram merge into one avatar (rank as a small corner badge),
  removing the separate rank tile and decluttering the row.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 23:03:05 +00:00
Claude
33f4c648e9 fix(graperank): don't create the operator master for a read-only follower crawl
`amy graperank followers <observer>` (no --relay) assembles the relay universe
via allKnownRelays, which read the reachability cache through ctx.reachability —
and that lazily derives the monitor key from the operator master, forcing a
passphrase prompt (or failing on a fresh machine) purely to READ kind:30166
records. The follower crawl signs nothing, so this defeats its anonymous path.

Read the reachability snapshot with a throwaway signer instead (snapshot() only
reads; the signer is used solely for writes) — the same trick `graperank status`
already uses to stay side-effect-free.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 22:57:25 +00:00
Claude
0feb0072a1 feat(media-servers): redesign whole screen as one canvas, drop tabs
Replaces the Servers/Local-cache segmented tabs with a single cohesive
scroll, and redesigns every zone of the screen:

- Upload priority: draggable, ranked server rows now carry a colored
  monogram (identity) and a "Primary" badge on #1, alongside the health dot.
- Add a server: the old inline URL field + a second 10-row recommended
  list are replaced by one inline add area — the URL field plus the
  recommended servers as a horizontal strip of add-chips that read as
  "done" once added (matched by host so URL normalization can't hide it).
- On-device cache: the old bare switch rows become a self-contained card
  with a storage icon, a detection-status dot, and the profile-pics-only
  sub-toggle nested under it.

Removes the segmented tabs and their now-dead strings. Same staged
Save/Cancel model, kind-10063 publish, drag utility, and health probe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 22:53:13 +00:00
Claude
ee217a42dc perf(commons): remember derived values in the moved note cards
Audit follow-up. These renderers sit in scrolling feeds; each recomputed
event parses / string builds directly in the composable body instead of
caching them against the (immutable) event — the CLAUDE.md rule-#4 pattern.
The cards are already skippable (immutable event params + strong skipping),
so this is work redone per composition (each item scrolling into view), not
per frame; still worth removing. These patterns pre-existed in the Android
originals and were carried over faithfully — this cleans them up now that the
code is shared.

- RelayDiscoveryCard: the heaviest — 6 `joinToString` + a `.sorted()` ran in
  the body. Now each display string (network / relay-type / requirements /
  supported-NIPs / accepted-kinds / geohashes, the requirements lock flag, and
  the relay-URL displayUrl()) is `remember`ed off its parsed list; the row
  visibility checks still key off the original lists so behavior is identical.
- CalendarRsvpCard: `status` / `calendarEventAddress` / `freebusy` parses now
  `remember(event)` instead of re-scanning tags every composition.
- CalendarCollectionCard: `title()` and `calendarEventAddresses().size` (which
  allocated a whole List just to read size) now `remember(event)`.
- PodcastValueSplits: the `recipients.filter{}` + `totalSplit()` now
  `remember(value)`.

Behavior is unchanged (same keys, conditions, and outputs). Verified:
:commons:compileKotlinJvm + :amethyst:compileFdroidDebugKotlin pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude
c4957ee8c0 fix(commons): base ColorScheme.isLight on background, not primary luminance
commons ColorScheme.isLight tested `primary.luminance() < 0.5f`. With the
default purple accent the primary is a deep purple in the light theme (lum
0.09) AND a light purple in the dark theme (lum 0.35) — both < 0.5 — so it
reported "light" in BOTH modes. The Android app decides the same thing from
the background (`background != Color.Black`); `background.luminance() > 0.5f`
is the multiplatform-safe equivalent (light bg ≈ 0.98, dark bg = 0.0).

Surfaced while reviewing the note-ui extraction: the new commons theme helpers
that branch on isLight — subtleBorder / replyModifier (card hairline borders)
and allGoodColor / warningColor (RelayDiscovery latency chips) — were rendering
their light-theme variant in dark mode, a regression vs the Android originals
which use the app's background-based isLight. This restores parity.

Also corrects two pre-existing consumers that had the same latent bug and now
behave correctly in dark mode (worth a dark-mode glance in review):
- UserAvatar → CachedRobohash light/dark variant selection;
- ChatTheme.chatBubbleBackground alpha.

Verified: :commons:compileKotlinJvm passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude
c18256a175 docs(plan): record note-ui-commons extraction progress + findings
Amends commons/plans/2026-07-16-note-ui-commons-extraction.md with a §0.1
"Progress & findings" section after landing 13 event kinds:

- a batch table (what moved, which seam mechanic each proved) and the shared
  commons theme now in place;
- Finding A: commons i18n was an unscoped prerequisite — wired commons into
  Crowdin so migrated strings/plurals keep every locale;
- Finding B: gate #1 (amethyst reads commons Res) is the real Tier-1 unblock,
  since most renderers share strings with a still-native screen;
- Finding C: "unused seam" (declares accountViewModel/nav but never calls
  them) is the cleanest Tier-1 signal;
- Finding D: commonMain bans Jackson (blocks MedicalData/MiniFhir);
- Finding E: platform leaves become opaque or typed @Composable slots (PS1
  bitmap icon, Roadstr map) and it works cleanly.

Also flips front-matter status to in-progress and annotates the Tier 0 / Tier
1 lists in §5 with what's done and what's left. The design (§1–§8) is
unchanged; §0.1 is the amendment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude
14c9f99bac refactor(commons): extract NIP-52 calendar collection + RSVP cards to commons
Twelfth batch of the note-ui-commons extraction — the calendar family, and
a fuller exercise of gate #1: strings shared with three native call sites
move to commons with no duplication.

- commons/ui/note/CalendarCollectionCard.kt: the calendar collection card
  (kind 31924) — title, description, event count.
- commons/ui/note/CalendarRsvpCard.kt: the RSVP card (kind 31925) — the
  going/maybe/not-going status, note, and target address.
  Both are pure value-in; the entries' unused accountViewModel/nav are dropped.
- Strings/plural migrated to commons: calendar_rsvp_going/maybe/not_going and
  the calendar_collection_count plural. Their native co-users now read them
  from commons Res instead of the Android res tree — CalendarRsvpRow (the
  interactive RSVP row), CalendarEventDetailScreen, and CalendarCollectionsView
  — so each key lives in exactly one place.
- amethyst keeps the thin RenderCalendarCollectionEvent / RenderCalendarRSVPEvent
  dispatcher entries.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude
fd4d9c10f7 refactor(commons): extract RelayDiscovery card to commons
Eleventh batch of the note-ui-commons extraction, and the first to exercise
gate #1 (app-side reads of commons Res) for a string shared with a native
screen — no duplication.

- commons/ui/note/RelayDiscoveryCard.kt: the NIP-66 relay discovery/monitor
  card (relay URL, latency health chips, network/relay-type/requirements/
  NIPs/kinds/topics/geohashes). Pure value-in — takes the quartz event; the
  entry's unused accountViewModel/nav are dropped.
- commons/ui/theme: adds ColorScheme.allGoodColor / warningColor (the
  green/amber status colors), mirroring the Android values.
- Migrated the 10 relay_monitor_* / relay_discovery_* strings the card uses
  into commons. Seven of them are shared with the native RelayInformationScreen,
  which now reads them from commons Res instead — so the keys live in exactly
  one place. relay_monitor_reports (screen-only) stays app-side.
- amethyst keeps the thin RenderRelayDiscovery(Note, …) dispatcher entry.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:09 +00:00
Claude
60a226891c build(amethyst): read commons Compose resources; drop a duplicated string
Enables app-side (still-native) screens to reference commons' generated
`Res` directly, so a string shared between a commons renderer and an
Android screen no longer has to be duplicated across the two resource
trees. commons already exposes `Res` publicly (publicResClass = true); the
only missing piece was the Compose-resources runtime on amethyst's
classpath — the JetBrains compose artifacts are already there transitively
via :commons, this just adds components-resources so
`org.jetbrains.compose.resources.*` resolves.

Proof + cleanup: the V4V split editor now reads
podcast_value_split_percent from commons `Res` instead of the Android res
tree, and that key is removed from amethyst — collapsing the duplication
introduced when PodcastValueSplits moved to commons. (podcast_value_for_value
stays app-side: it feeds an Android-int ResourceToastMsg, which has no
Compose-resource form.)

This unblocks the next wave of renderer extractions whose strings are
shared with native screens (relay discovery, calendar, …) — those screens
can now switch to commons `Res` rather than forcing a duplicate.

Verified: :amethyst:compileFdroidDebugKotlin and :commons:compileKotlinJvm
both pass; touched strings.xml well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:07 +00:00
Claude
62c2d7755d refactor(commons): extract PodcastValueSplits to commons
Ninth batch of the note-ui-commons extraction, completing the podcast
display family (badge/link/soundbite atoms landed earlier).

- commons/ui/note/PodcastValueSplits.kt: the Podcasting-2.0 value-for-value
  split breakdown card (header, split hint, one row per recipient with its
  percentage). Pure value-in — takes a quartz PodcastValue.
- commons/ui/theme: adds ColorScheme.grayText (onSurface @52%); reuses the
  Size5dp added with the ActivityCard batch.
- Consumers (PodcastEpisode, PodcastMetadata) re-point to commons.

Strings: podcast_value_zap_split_hint is renderer-only and moves fully to
commons. podcast_value_for_value and podcast_value_split_percent are also
used by the native V4V split editor — for_value via an Android-int
ResourceToastMsg toast that has no Compose-resource equivalent, and the
editor can't reference commons' generated Res until amethyst gains the
compose-resources dependency. So those two keys are intentionally duplicated
(kept in amethyst for the editor, copied to commons for the shared card).
The duplication collapses once amethyst can read commons Res or the editor
itself moves; both sources stay in Crowdin meanwhile.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:50:06 +00:00
Claude
fb471a2e6b refactor(commons): extract Roadstr road-event card to commons
Eighth batch of the note-ui-commons extraction. The two Roadstr renderers
(report kind 1315, confirmation kind 1316) are self-contained except for the
map hero, which is a platform tile view.

- commons/ui/note/RoadEventCard.kt: RoadEventReportCard(event, map) and
  RoadEventConfirmationCard(event, map). Commons owns all the logic — the
  category emoji/color/label palette, the geohash→point resolution, the
  freshness-based pin opacity, the floating category pill, and the layout.
  The map arrives as a typed RoadEventMap slot
  (lat, lon, pinColor, pinEmoji, pinAlpha) so the native LocationPreviewMap
  stays in the app. Labels resolve via commons Res.
- amethyst keeps thin RenderRoadEventReport(Note) /
  RenderRoadEventConfirmation(Note) entries that decode the event and pass
  LocationPreviewMap as the slot.
- Migrated all 18 road_event_* strings with their locale translations into
  commons/composeResources; deleted the amethyst copies.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:49:36 +00:00
Claude
05f7da6814 refactor(commons): extract activity-card building blocks to commons
Seventh batch of the note-ui-commons extraction. The activity-card slot
composables — the gradient frame, kind badge, header row, amount row, and
pill shared by the reaction / zap / nutzap renderings — are pure
value/slot-in with no strings, so they move as-is.

- commons/ui/note/ActivityCard.kt: ActivityCardFrame, ActivityBadge,
  ActivityPill, ActivityHeaderRow, ActivityAmountRow, and the LikeTint color.
- commons/ui/theme/Sizes.kt: shared Size16Modifier (and Size5dp for the next
  batch), mirroring the Android ui/theme values.
- Consumers (Reaction, ZapEvent, Nutzap) re-point their imports to commons.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:49:35 +00:00
Claude
71d15bf916 refactor(commons): extract podcast badge/link/soundbite atoms to commons
Sixth batch of the note-ui-commons extraction. These are the shared
Podcasting-2.0 display atoms the show/episode renderers compose from; they
already take plain values + onClick/onPlayFrom lambdas, so they move as-is.

- commons/ui/note/PodcastChips.kt: PodcastBadge + PodcastLinkChip (now
  public), pure value/lambda-in pills.
- commons/ui/note/PodcastSoundbites.kt: the soundbite "jump to the good
  part" chip row; the play label resolves via commons Res, playback stays
  controller-agnostic through the onPlayFrom callback.
- Migrated podcast_play_soundbite with its locale translations into
  commons/composeResources; deleted the amethyst copy.
- Consumers (PodcastEpisode, PodcastMetadata, PodcastEpisodeAudioPlayer,
  PodcastChaptersView) re-point their imports to commons.

PodcastValueSplits is intentionally left for a follow-up: two of its strings
are shared with the native V4V split editor, so it needs the editor
re-pointed in the same change.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:49:35 +00:00
Claude
3b96dc2f82 refactor(commons): extract PS1 memory-card save card to commons
Fifth batch of the note-ui-commons extraction, and the first to use the
platform-specific @Composable slot pattern the plan calls for (§3e/§4).

The PS1 save card (kind 38192) is pure value-in except for its animated
16×16 icon, which is decoded from raw memory-card pixels via Android Bitmap
APIs (createBitmap/setPixels/asImageBitmap) that can't move to commonMain.

- commons/ui/note/Ps1SaveCard.kt: the card takes decoded primitives (title,
  filename, region, block number, blank flag, hex preview) plus an
  `icon: (@Composable () -> Unit)?` slot; a null icon falls back to the
  floppy-disk emoji prefix. Labels resolve via commons Res.
- amethyst keeps the thin RenderPs1Save(Note) entry, which decodes the event
  and supplies the native Ps1SaveIconImage (the bitmap/frame-clock animation)
  as the icon slot.
- Migrated ps1_save_title/ps1_save_block/ps1_save_empty_slot with all locale
  translations into commons/composeResources; deleted the amethyst copies.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:49:34 +00:00
Claude
a9ff6f5fbb refactor(commons): extract Birdstar Birdex/detection cards to commons
Fourth batch of the note-ui-commons extraction. The two Birdstar cards
(Birdex kind 12473, bird detection kind 2473) are pure value-in renderers:
they only read decoded tag values and open one Wikidata link, with no
AccountViewModel/INav.

- commons/ui/note/BirdexCard.kt: BirdexCard(BirdexEvent) and
  BirdDetectionCard(BirdDetectionEvent). The scientific-name link reuses the
  commons ClickableUrl atom; labels resolve via commons Res.
- amethyst keeps thin RenderBirdex(Note) / RenderBirdDetection(Note) entries
  that decode the event and call the shared cards.
- Migrated bird_detection_title (string) plus birdex_species_count and
  birdex_species_preview_more (plurals) with all locale translations from
  amethyst/res into commons/composeResources; deleted the amethyst copies.

Verified: :commons:compileKotlinJvm and :amethyst:compileFdroidDebugKotlin
both pass; every touched strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:48:33 +00:00
Claude
b2c78d6b76 refactor(commons): extract GitDiffView renderer to commons
Third batch of the note-ui-commons extraction. GitDiffView is a pure
presentation composable — it takes a quartz ParsedPatch + a Modifier, with
no AccountViewModel/INav/Note — and its syntax-highlighting helper
(CodeHighlighter) already lives in commons, so it moves wholesale.

- commons/ui/note/GitDiffView.kt: the GitHub-style file-by-file diff view
  (stat summary, per-file collapsible cards, +/- line coloring, intraline
  emphasis, syntax highlighting). Labels resolve via commons Res.
- Migrated git_diff_binary (string) and git_diff_files_changed (plural,
  all quantity forms across 11 locales) from amethyst/res into
  commons/composeResources; deleted the amethyst copies. This exercises the
  now-wired commons Crowdin pipeline for a plural resource.
- Callers (Git.kt, GitPullRequestChanges.kt, GitCommitLog.kt) re-point the
  import to commons; the call sites are unchanged.

Verified: :commons:compileKotlinJvm (plural accessor generation) and
:amethyst:compileFdroidDebugKotlin both pass; every touched strings.xml is
well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:48:31 +00:00
Claude
a839ef226a refactor(commons): extract GitStatusPill to commons; wire commons i18n
Second batch of the note-ui-commons extraction, and the load-bearing
setup for every string-bearing renderer that follows.

Commons had no translation pipeline — crowdin.yml synced only the Android
app's strings.xml — so moving an already-translated string to commons
Res.string would drop its translations and remove it from future syncs.
This commit fixes that first:

- crowdin.yml: add commons/composeResources as a second Android source
  with the same language mapping, so shared keys keep flowing through
  Crowdin.
- Migrate the four git_status_* pill labels (open/merged/closed/draft)
  and all 56 existing locale translations from amethyst/res into
  commons/composeResources, deleting the amethyst copies. No language
  regresses.

Then the renderer split (NIP-34 git status pill):

- commons/ui/note/GitStatusPill.kt: the StatusKind enum + the pure
  GitStatusPill(kind, modifier) presentation composable, resolving labels
  via commons Res.string. Shared by Android and Desktop.
- amethyst keeps the thin GitStatusPill(targetIdHex, …) entry that reads
  the account-bound GitStatusIndex and delegates to the commons pill.
- Callers (GitItemListRow, Git.kt) re-point StatusKind to commons.

Verified: :commons:compileKotlinJvm (accessor generation + all 56 locale
qualifiers accepted) and :amethyst:compileFdroidDebugKotlin both pass;
every modified/added strings.xml is well-formed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:47:39 +00:00
Claude
870aeb4b99 refactor(commons): extract CodeSnippet + EcashMint renderers to commons
First pilot of the note-ui-commons extraction plan
(commons/plans/2026-07-16-note-ui-commons-extraction.md, Tier 0). These
two event-kind renderers take plain quartz events with no AccountViewModel,
INav, or R.string, so they move to commons/ui/note/ almost verbatim:

- EcashMintCard: RenderCashuMint / RenderFedimint / RenderMintRecommendation
  move wholesale (already past the seam — the NoteCompose dispatcher hands
  them a decoded event). NoteCompose + ThreadFeedView imports re-pointed.
- CodeSnippetCard: the feed preview and thread header layouts move to
  commons; the Android app keeps a thin RenderCodeSnippetEvent(Note) entry
  that decodes the Note and calls the shared card.
- NoteBorders: shared QuoteBorder/SmallBorder/StdHorzSpacer/StdVertSpacer/
  subtleBorder/replyModifier theme primitives in commons so the migrated
  cards share one visual vocabulary. Mirrors the Android ui/theme values;
  the Android copies are deleted once every renderer that uses them moves.

Desktop now renders the identical cards, so the two front ends can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gmrt3jwYPDJ38MJGJ6GaNr
2026-07-17 22:47:37 +00:00
Claude
331dca79cd feat(graperank): run crawl/score without a personal account
crawl and score used Context.open, which requires an account solely to default
the observer to the logged-in user — but neither uses account keys: crawl never
signs, and score signs cards with the machine-level operator key (~/.amy/operator/,
independent of any account). Switch both to Context.openOrAnonymous and require
an explicit OBSERVER when running anonymously, matching `graperank followers`.

A machine acting as a GrapeRank provider for arbitrary observers no longer needs
a personal account (the operator key still needs its SecretStore passphrase, as
before — that's the reachability monitor + card signer, a real secret).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 22:41:26 +00:00
Vitor Pamplona
90acafe389 Merge pull request #3624 from vitorpamplona/claude/relay-icons-reaction-gallery-x4uzmk
Move relay badges from author column to reaction gallery
2026-07-17 18:31:10 -04:00
Vitor Pamplona
23597ab883 Merge pull request #3623 from vitorpamplona/claude/messages-settings-toggles-s1qo9e
Add per-chat-type load toggles to Messages settings
2026-07-17 18:30:57 -04:00
Claude
ed9e02ac7f fix: reorder relay line last, align its icon, show expand button for relays
Address gallery layout feedback for the "accepted by relays" line:
- Move the relay line to the bottom of the reaction detail gallery.
- Use NotificationIconModifier for the Dns icon box so it lines up
  horizontally with the zap/like/nutzap category icons (which carry the
  same 5dp end padding).
- Show the reaction-gallery expand button whenever the note has relay
  URLs, not only when it has reactions/zaps/boosts, so the always-present
  relay line is reachable. Gate it with a lightweight boolean relay
  observer (no list allocation, no sampling) since it runs per feed note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 22:20:49 +00:00
Vitor Pamplona
3316dfcd7a Merge pull request #3622 from vitorpamplona/claude/concord-epoch-backfill
feat(concord): backfill channel history across Refounding epochs
2026-07-17 18:04:16 -04:00
Vitor Pamplona
880a980f82 Merge pull request #3616 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 17:51:09 -04:00
vitorpamplona
cbcb8d42ba chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 21:49:24 +00:00
Vitor Pamplona
2fee4e800f Merge pull request #3620 from vitorpamplona/claude/medicaldata-jackson-kotlin-serialization-c15i39
Replace Jackson with kotlinx.serialization for FHIR parsing
2026-07-17 17:49:17 -04:00
Vitor Pamplona
1d5b08a128 docs(concord): mark epoch-walking backfill steps 1-3 done
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
602c6a90b2 feat(concord): page channel history across epochs to the true start
The backward "load older" pager REQ'd only the current epoch's Chat Plane, so
deep scroll stopped at the last Refounding and showed "All caught up" while
older messages sat under prior-epoch planes.

Widen the history REQ authors to the union of the channel's plane pubkeys
across every held epoch (ConcordCommunitySession.channelPlaneAddressesAllEpochs).
The relay serves them interleaved by created_at, so one backward `until` sweep
walks the whole cross-Refounding timeline and `exhausted` (the "All caught up"
signal) now means every epoch is drained, not just the current one. The pager
is unchanged — it only tracks until/limit per relay and forwards createdAt; the
prior-epoch wraps decrypt on the normal ingest path (already epoch-aware).

Test: ConcordCommunitySessionTest asserts channelPlaneAddressesAllEpochs returns
current + prior planes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
891e6ced91 feat(concord): backfill prior-epoch channel history from held roots
A CORD-06 Refounding rotates the community_root and bumps the epoch, so each
channel's pre-refounding messages live under a different derived Chat Plane
per epoch. The client only ever subscribed to the current epoch's plane, so
older history was invisible and the feed said "All caught up" while months of
messages sat on the same relays under prior-epoch stream keys.

The account already persists each rotated-out root in
ConcordCommunityListEntry.heldRoots; this consumes them on the read side:

- ConcordActions.historicalChannelPlanes() re-derives each folded channel's
  plane at every held epoch (bounded by MAX_BACKFILL_EPOCHS = 8; 0 disables).
- ConcordCommunitySession keeps a historicalChannelKeysByAddress map (derived
  in refold, since channels are known only after a fold) and folds it into
  channelAddresses() (subscribe), streamKeys() (NIP-42 AUTH), and ingest()
  (decrypt with the matching epoch, isBoundTo per epoch). Channel ids are
  epoch-invariant, so historical messages merge into the same channel feed.
- ConcordSubscriptionPlanner.channelPlaneSubs appends the historical planes,
  so the existing filter assembler subscribes to them unchanged.

Writes / moderation / rekey stay strictly on the current epoch. Cross-validated
against amy: the app now subscribes to the exact prior-epoch plane pubkeys
`amy concord read --epoch 0` proved hold the older Soapbox #nostrhub messages.

Tests: ConcordCommunitySessionTest.ingestsPriorEpochWrapsFromAHeldRoot,
ConcordSubscriptionPlannerTest.channelSubsAlsoCoverPriorEpochPlanesForHeldRoots.

Follow-up (plan step 3): BackwardRelayPager epoch-stepping so deep "load older"
scroll crosses epochs to the true start.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 17:48:45 -04:00
Vitor Pamplona
60d492e2bc Merge pull request #3619 from vitorpamplona/claude/nip-84-tag-rendering-0o9owh
NIP-84: Support W3C TextQuoteSelector for highlight context
2026-07-17 17:47:18 -04:00
Claude
db586e30d3 feat: render NIP-84 highlights from web highlighter clients
Web-based highlighter clients publish kind:9802 highlights with W3C Web
Annotation selectors (textquoteselector / textpositionselector /
rangeselector) instead of a NIP-84 `context` tag. These were previously
ignored, so the highlight rendered without its surrounding paragraph and
the "jump to page" link couldn't disambiguate repeated quotes.

- Parse the W3C textquoteselector into TextQuoteSelectorTag (exact/prefix/
  suffix; a "-" or empty exact is treated as a placeholder since the quote
  lives in .content).
- HighlightEvent.contextOrReconstructed() prefers an explicit `context`
  tag and otherwise rebuilds the paragraph from prefix + content + suffix,
  so the in-context bolding still works.
- Build a disambiguated Text Fragment URL (`#:~:text=prefix-,exact,-suffix`)
  from the selector's prefix/suffix so the source link scrolls to the
  correct occurrence.

The position/range selectors are left unparsed; they only matter for an
in-app live-page re-highlighter, which we don't have.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Di4UurD9SQGrpScX7uy2Kh
2026-07-17 21:40:21 +00:00
Claude
40acd69781 refactor(media-servers): cache health probes, prune stale status, save tab
Follow-up on the audit of the Media Servers redesign:

- Add a process-wide TTL cache (60s) to MediaServerHealthProbe so probe
  results survive the screen's ViewModel being recreated on each open;
  the ViewModel reuses fresh cached status instead of re-hitting the
  network, and skips launching a probe when one is already in flight.
- Prune the _health map on refresh/remove so it can't grow unbounded as
  servers are added and removed within a session.
- Persist the selected tab across configuration changes (rememberSaveable).
- Restore the screen's intro caption, reusing the previously orphaned
  set_preferred_media_servers string.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 21:33:26 +00:00
Claude
dc157e32d2 fix(graperank): page through ALL followers, not just the first limit
FollowerCrawler set the reverse-lookup filter's `limit` to the page size, but
fetchAllPages treats a filter `limit` as the TOTAL cap across all pages and
stops paging once it's reached — so the crawl silently capped at ~500 followers
per relay (verified live: relay.damus.io returned exactly 500 for a
many-thousand-follower observer).

Leave the filter limit null by default so pagination walks the whole result set
(the same observer now returns 12,823 followers from damus alone); Config gains
`maxPerRelay` and the CLI a `--max N` flag to opt back into a bounded spot check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 21:32:16 +00:00
Claude
1daa871d95 refactor: move MiniFhir/MedicalData parsing from Jackson to Kotlin Serialization
Replace the Jackson-based FHIR resource parser with kotlinx.serialization.
The `resourceType` polymorphism is now handled by a JsonContentPolymorphicSerializer
that dispatches to the modeled types (Practitioner, Patient, Bundle,
VisionPrescription) and falls back to a new UnknownResource for anything else,
so a Bundle mixing known and unknown resources still parses.

The reader is lenient (ignoreUnknownKeys, isLenient, explicitNulls=false,
coerceInputValues) so we parse what we can and tolerate the missing or extra
fields that many FHIR implementations add.

Adds MiniFhirTest covering the vision-prescription bundle, extra/unknown field
tolerance, unknown-resourceType fallback, mixed bundles, and garbage input.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MUeR8YFNHvBELCaLRDTpCn
2026-07-17 21:27:31 +00:00
Vitor Pamplona
3254b90b19 Merge pull request #3618 from vitorpamplona/claude/amy-nip46-bunker-concord-epoch-diag
fix(nip46): remote-signer pubKey is the user identity + amy Concord epoch tooling
2026-07-17 17:25:39 -04:00
Claude
76dd0a0f16 fix: size accepted-by-relays icons to match gallery author pictures
The relay favicons rendered at 17dp while the zap/boost/reaction gallery
rows use 35dp author pictures, so the "accepted by relays" line looked
out of scale. Parameterize RenderRelay with a box size and icon modifier
(defaults unchanged for the existing compact relay lists) and render the
gallery relays at 35dp with MediumRelayIconModifier to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 21:11:34 +00:00
Claude
ede4fea1cd fix: don't render empty reaction gallery row without relays or reactions
Restore a visibility guard on the reaction detail gallery so a note with
neither relays nor reactions no longer paints an empty padded row when
the gallery is expanded. The relay flow is now collected once via
observeNoteRelays and reused both for the guard and to render the
"accepted by relays" line, avoiding a second subscription.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 20:28:16 +00:00
Vitor Pamplona
56c99bb7b6 Merge pull request #3617 from vitorpamplona/claude/community-invite-nav-mr0qzv
Prevent re-joining Concord communities via old invite links
2026-07-17 16:20:36 -04:00
Claude
e3e85994f2 fix: drop Concord invite screen from back stack after forwarding to the community
ConcordInviteScreen auto-redeems and forwards to Route.ConcordServer, but it did
so with nav.newStack(). Since the community route isn't in the back stack yet,
newStack's popUpTo(target) matches nothing and the invite screen stays underneath.
Pressing Back from the community then reveals the invite screen, whose plain
remember(link) state resets to Working, re-runs joinConcordViaInvite, and forwards
right back — trapping the user in a Back→forward loop.

Forward with popUpTo(ConcordServer, ConcordInvite::class) instead, matching the
sibling group-creation screens (CreateGroupScreen, RelayGroupMetadataScreen), so
the invite screen is removed and Back returns to wherever the invite was opened.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:47:17 +00:00
Claude
7649c7f94d feat(messages): use PolicyCard selection cards for NIP-29/Concord display modes
Replace the radio-row inline/grouped pickers with the same bordered PolicyCard
selection cards the Relay Authentication "When to authenticate" picker uses:
an accent-colored icon + label + description, a check when selected, and an 8dp
stack. Inline uses the list icon, grouped uses the folder icon (both already in
the Material Symbols subset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
2026-07-17 19:41:18 +00:00
Claude
5f65405b08 feat(graperank): add reverse follower crawl (amy graperank followers)
The outbox model can't find an observer's followers — you don't know a
follower exists until you've seen their kind:3, so you can't route to their
outbox first. FollowerCrawler casts a wide net instead: it asks as many relays
as possible for kind:3 lists that #p-tag the observer, paging each relay past
its per-REQ cap via fetchAllPagesFromPool, verifies with ParallelEventVerifier,
keeps only lists that genuinely tag the observer, dedups by id, and
group-commits to the store.

Each follower's list is a full contact list, so persisting it also enriches the
graph a later `graperank score` builds — every follower becomes a FOLLOW edge
into the observer.

CLI: `amy graperank followers [OBSERVER]` assembles "all possible relays" from
the reachability-cache live set + every kind:10002/30166 relay in the store +
the index/aggregator relays, skipping proven-dead relays. Runs anonymously (no
signing) when given an explicit observer. Tunable via
--relay/--page-limit/--timeout/--relay-concurrency/--insert-batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 19:34:06 +00:00
Claude
c8ff1bb18f feat(graperank): persist follower count and hop distance on trust cards
Each kind:30382 GrapeRank card now carries two more public tags alongside
`rank`:

- `followers` — the number of the target's followers whose own score clears a
  threshold (`--followers-threshold`, default 0.02), mirroring Brainstorm's
  trusted-follower cutoff.
- `hops` — the shortest follow-graph distance from the observer (1 = a direct
  follow), matching the `hops` field on Brainstorm's ScoreCard.

New `HopsTag` (the `followers`/`FollowerCountTag` already existed) is wired
through the ContactCardEvent tag accessors/builders. TrustGraph gains
`hopsFrom` (a follow-only BFS over the compact int-CSR) and
`trustedFollowerCounts`; the out-CSR now packs the relation code so a forward
walk can filter FOLLOW edges. The publisher's `reconcileLocal` takes a richer
`ScoredCard` and diffs the full (rank, followers, hops) triple, so a card
re-signs when any of them moves and older cards migrate onto the new tags once.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xc3Wm4qVCrAGvSAotTUVt4
2026-07-17 19:34:06 +00:00
Claude
9717af28ee Merge remote-tracking branch 'origin/main' into claude/messages-settings-toggles-s1qo9e 2026-07-17 19:34:04 +00:00
Claude
f759ef6ca7 feat: modernize Media Servers settings with reorderable priority list
Redesigns the Media Servers screen around the fact that Blossom uploads
mirror in list order, plus a lightweight reachability check per server.

- Split the screen into "Servers" / "Local cache" segmented tabs, moving
  the local-cache switches into their own tab to declutter the list.
- Make the server list drag-to-reorder using the shared RelayDragState
  utility; list order is the upload/fallback priority (row #1 first).
- Add a rank badge per row (accent-filled for the primary target) and a
  reorder hint.
- Add a per-server health dot (Online / Slow / Offline / Checking) backed
  by a one-shot HEAD probe through the account's Tor-aware preview client.
- Add moveServer() + health StateFlow to BlossomServersViewModel; probe on
  load and when a server is added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GJ77Hm5L7fXEbPWbUds1iA
2026-07-17 19:33:43 +00:00
Claude
2ac9cc81f7 feat: skip re-join when redeeming a Concord invite for a community already joined
When redeeming a Concord invite link (`…/invite/<naddr>#<fragment>`) whose
resolved community is already in the joined list, return
`ConcordInviteResult.Joined` immediately instead of re-following and
re-announcing a Guestbook JOIN (kind 3306). The invite screen already forwards
to `Route.ConcordServer(communityId)` on `Joined`, so reopening an old invite
for a community you're already in simply takes you to it rather than spamming
the community relays with a fresh join every time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MeJJCDLdvi4KQEuozcMMBh
2026-07-17 19:33:43 +00:00
Claude
2f3c09c41d feat: move relay icons from author badge to reaction gallery
Complete UI Mode used to paint the "seen on" relay pills below the
author avatar (BadgeBox / RelayBadges), hidden from every other mode.
As part of retiring Complete UI Mode, surface those relay icons for
everyone instead: they now appear as an "Accepted by relays" line at
the top of the expanded reaction gallery.

- Add AcceptedByRelaysGallery: a reaction-gallery line (Dns icon +
  FlowRow of relay favicons) mirroring the OnchainZap/Nutzap gallery
  pattern, subscribing to the note's sampled relay flow.
- Wire it as the first line of ReactionDetailGallery and drop the
  hasReactions gate so the line shows whenever the gallery is opened
  (a note almost always has at least one relay).
- Remove the complete-mode-only BadgeBox and the now-dead RelayBadges
  composable and the relayBadges slot in NoteComposeLayout (reindexing
  the single-pass measure/place logic).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GVPYTTnMbxwi5hUAagsKVc
2026-07-17 19:33:04 +00:00
Vitor Pamplona
f380d92498 Merge pull request #3615 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 15:31:14 -04:00
Vitor Pamplona
d1f267a2a3 Merge pull request #3613 from vitorpamplona/feat/relay-req-refusal-suppression
feat(relay): stop re-sending REQs relays structurally refuse
2026-07-17 15:31:06 -04:00
davotoula
f28b6381e3 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:26:39 +00:00
David Kaspar
bf3e0df24d Merge pull request #3614 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 20:24:40 +01:00
davotoula
17c014ac1b chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 19:19:50 +00:00
davotoula
fd430ae429 update cz, se, pt, de 2026-07-17 20:16:55 +01:00
Vitor Pamplona
10d88afea3 feat(relay): stop re-sending REQs relays structurally refuse
Relays that can't serve a request (a NIP-50 search-only relay pulled into
the feed, a write-only relay, a relay whose filter shape is rejected) were
being hammered with the same doomed REQs. Observed on a 40s cold start:
search.nos.today CLOSED 13-14x ("error: search filter is required") across
6 subscriptions, plus repeated "restricted: does not accept REQs" and
"unsupported: too many filters". The reconnect path replayed refused REQs on
every reconnect, and many different subscriptions kept hitting the same
capability wall.

Two complementary, purely-quartz mechanisms (so the app and Amy both benefit
with zero wiring):

- Per-subscription refusal memory (RequestSubscriptionState + PoolRequests):
  a filter a relay CLOSES is not replayed to that relay across reconnects
  until it meaningfully changes or a REQ succeeds (EOSE/event). Never applies
  to auth-required (the auth subsystem re-signs and replays) or rate-limited
  (the adaptive limiter spaces it out).

- Per-relay capability block (RelayReqRefusals): after 2 refusals, classify a
  relay SEARCH_ONLY (suppress only non-search filters; genuine search REQs
  still flow) or NO_READS (suppress all), from narrow substring markers that
  deliberately avoid auth-conditional messages. A fully-blocked relay is
  dropped from PoolRequests.desiredRelays so the pool disconnects it, closing
  the idle socket rather than keeping it open with every REQ suppressed. A
  SEARCH_ONLY relay stays connected while any subscription carries a search
  filter for it, so the separate search path is unaffected.

Adds UNSUPPORTED to MachineReadablePrefix (relays send "unsupported:"; parse()
returned null on it before).

Device-verified: search.nos.today now Connecting -> OnOpen -> 2x Closed ->
Disconnected; sendit.nosflare.com (NO_READS) -> 3x Closed -> Disconnected;
feed event volume unchanged (kind-1 from 17 relays) - no coverage loss.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 14:58:03 -04:00
Claude
ae6e1e9c2d feat(messages): per-type load toggles for the Messages inbox
Add a "Conversations to load" section to Settings › Messages that lets users
choose which chat protocols the inbox loads: NIP-04, NIP-17, NIP-28, NIP-29,
Marmot (MLS), Concord, Geolocation (geohash) and Ephemeral chats.

Disabling a type both hides its rows from the inbox and drops its kinds/
assemblers from the always-on downloading routes:

- New ChatFeedType enum (commons) with stable persisted codes.
- AccountSettings.enabledChatFeeds (defaults to all-on); persisted per-device
  in LocalPreferences as the disabled set, so absence = everything on and any
  future type defaults enabled.
- ChatroomListKnown/NewFeedFilter gate each section (NIP-04 vs NIP-17 split by
  event type) in both the full build and the incremental update paths.
- Each downloading route (rooms-list NIP-04/28/ephemeral/geohash, account gift
  wraps + Marmot, NIP-29 joined groups, Concord) returns no filters when its
  type is off and re-arms via a shared launchChatFeedToggleObserver.
- AccountFeedContentStates rebuilds both tabs when a toggle flips.
- Modernized MessagesSettingsScreen: colorful accent switch cards per type; the
  NIP-29/Concord display-mode options now only appear while their type is on.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016RbzL9cZk1h88kE7ErgjG5
2026-07-17 16:22:20 +00:00
David Kaspar
cf4ea766aa Merge pull request #3608 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-17 17:22:17 +01:00
davotoula
e2128d2b6e chore: sync Crowdin translations and seed translator npub placeholders 2026-07-17 16:21:07 +00:00
davotoula
6dddb3b9c9 Code review:
- clarify no-op lifecycle callback comments
2026-07-17 16:57:27 +01:00
davotoula
ef7c10cef9 fix(sonar): address maintainability smells in resource-usage and CLI code
- ForegroundTracker: document empty ActivityLifecycleCallbacks overrides
- ResourceUsageReportAssembler: extract duplicated markdown table separator into a constant
- GroupMetadataCommands: use shared File.deleteOrWarn helper for temp-file cleanup
- fold private deleteOrWarn into shared commons helper
2026-07-17 16:57:05 +01:00
Vitor Pamplona
7b5ddd3637 Merge pull request #3612 from vitorpamplona/claude/amethyst-nip46-signer-njao2v
Add NIP-46 remote signer (bunker) support with consent UI
2026-07-17 11:37:04 -04:00
Claude
98c53ed907 docs(nip46): record the amy CLI interop gates in the signer checklist
Document this session's CLI additions in the living NIP-46 device checklist
(no separate plan doc for two flags — they're covered by `amy --help`):

- a "CLI interop driver (amy)" section covering amy on both sides — client
  (login bunker:// / --nostrconnect [--perms]) and signer (bunker --perms /
  --interactive) — as the reproducible interop harness.
- a repro command on the NostrConnect informed-consent item: `amy login
  --nostrconnect --perms sign_event:1,nip44_encrypt` emits a perms-carrying
  offer that drives the app's consent sheet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:23:42 +00:00
Claude
13bc33ad70 feat(cli): gate amy bunker with --perms and interactive terminal approval
Until now the CLI bunker auto-approved every request (it hosts the
operator's own key, so the pairing secret was the only gate). That left
two NIP-46 signer behaviors the harness couldn't exercise: a signer that
*rejects* disallowed ops, and a signer that asks a human live.

Add two opt-in gates to both `amy bunker` and `amy bunker connect`:

- `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the
  listed ops; anything else is rejected. Fully scriptable/headless. This
  is the server-side mirror of the client's `--nostrconnect --perms`, so
  an interop run can now test a client against a rejecting signer.
- `--interactive` keeps the bunker listening and prompts `y/N` on the
  terminal for any op the policy doesn't already allow, so the operator
  approves/rejects each request live. TTY-guarded (errors on a piped
  stdin), default-deny, prompts serialized by a mutex because the service
  dispatches requests concurrently. Composes with `--perms` (auto-allow
  the safe ops, prompt for the rest) — mirroring Amethyst's Reasonable
  policy.

Neither flag → unchanged auto-approve behavior. Thin assembly: the perms
parsing and request→op mapping are reused from commons
(`Nip46PermissionAuthorizer.parsePerms` / `toSignerOp`), both already
unit-tested there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:38 +00:00
Claude
0f1ced8682 feat(cli): let amy login --nostrconnect request perms
The Amethyst signer now honors a nostrconnect offer's `perms=` for
informed consent, but the CLI client had no way to emit one — so the new
consent flow couldn't be driven from `amy`, the project's interop-test
harness.

Add a `--perms` flag to `amy login --nostrconnect`, threaded into the
offer through quartz's NostrConnectURI (which already builds/parses the
param). Also keep the parsed perms on NostrConnect.Offer and surface a
client's requested perms in `amy bunker connect`, so an operator can see
what an app-side signer would be asked to pre-grant (the CLI bunker still
auto-approves the operator's own key — perms is not a gate there).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:37 +00:00
Claude
1ebc4f0ada feat(nip46): informed consent for nostrconnect perms
Closes the one real gap from the "first-class" review. The nostrconnect flow
treated the paste/scan as blanket consent and, having no dialog, had to DROP the
app's declared sensitive perms to avoid a silent grant.

Now first contact via nostrconnect shows a connect sheet with the app's identity,
the account it would act as, the exact permissions it declared (rendered
human-readably — "Sign notes (kind 1)", "Encrypt messages", …), and a trust
picker — before anything is published or granted. Approving connects and
pre-grants exactly those declared ops (including sensitive ones the user just
reviewed) unless they pick Paranoid; Cancel/Block declines and nothing is
registered. A re-pair of a known app skips the sheet and keeps the user's prior
trust/per-op decisions.

Adds SignerConnectInfo.requestedPermissions + rendering, a
Nip46ConsentBridge.requestNostrConnectConsent path, and a ConnectResult.Declined.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:37 +00:00
Vitor Pamplona
5d9d75e2c4 fix(accounts): serialize Account construction so concurrent loaders can't build twins
The UI login path (AccountSessionManager) and the background preloaders
(RegisterAccounts, EventNotificationConsumer, loadAllWritableAccounts) race
loadAccount's check-then-create on cold start and after account switches.
Both saw an empty cache and both built an Account: the loser was never
cancelled, so its Nip46SignerState kept answering bunker requests with a
NostrSignerExternal no Activity ever registers an Amber launcher on — every
sign failed 'No activity to launch from' while the twin raced error replies
to NIP-46 clients and doubled every consent prompt. Reproduced on-device
(f8ff11c7 constructed twice 10ms apart); gone after the lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 15:18:36 +00:00
Claude
3a85a4f982 feat(nip46): per-account consent sheets + expandable per-item preview
Two follow-ups on the batched-consent sheet:

One account per sheet. The consent coordinator is process-wide, so a batch could
bundle requests for different logged-in accounts. Instead of mixing them, the
Activity now renders only the oldest-pending account's group; when that clears,
the next account's requests render as their own sheet. The account moves to a
header (avatar + "signing as <name>") since every row in a sheet now shares it.
Dismissing denies only that account's group.

Expandable per-item preview. Each batch row is collapsed to app · operation +
a one-line excerpt; tapping it expands the full detail so the user can inspect
exactly what they're signing/encrypting/decrypting — the unsigned event rendered
as a NoteCompose (with the JSON toggle), or the encrypt/decrypt plaintext.
Extracted that rich content block into a shared SignerConsentPreview reused by
the single-request dialog and each expanded row.

Removed the now-unused denyAllPending (dismissal is per-group / per-request).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude
5f723bf607 feat(nip46): show the signing account on each batched-consent row
The consent coordinator is process-wide, so a batched sheet can bundle requests
for different logged-in accounts. The rows showed only the app + operation, not
which identity would sign — against the "make it clear which account is signing"
rule the single-request dialog already follows. Each row now carries the signing
account's avatar + name, so a mixed-account batch is unambiguous.

(The batch stays a compact triage list — it does not render a full NoteCompose
preview per item the way the single-request dialog does.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude
62c82aef68 fix(nip46): two batched-consent races found in review
An independent review of the concurrent-servicing changes found two real bugs
(the quartz/authorizer concurrency core reviewed clean):

- Notification TOCTOU. SignerConsentCoordinator did a non-atomic
  "if pending empty → cancel notification" in the resolving request's finally.
  A request arriving concurrently could post the shared full-screen-intent
  notification between another request's empty-check and its cancel, wiping the
  new request's only surface while backgrounded — it then sat unseen until the
  120s timeout denied it. Add/show and remove/empty-check/cancel now run under
  one surfaceLock, so a live request's notification can't be cancelled out.

- Batched selection re-seeded to all-selected on any pending-set change
  (fail-open). Because requests are serviced concurrently, the pending set
  changes under an open sheet; re-seeding silently re-checked deselected items
  and auto-checked newly-arrived requests, so "Allow selected" could grant ops
  the user deselected or never saw. Now seed once and reconcile incrementally
  (selected ∩ tokens): deselections survive and a new request is never
  auto-selected. Also default the batch "Remember" toggle off.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:36 +00:00
Claude
fd5556609f fix(nip46): audit fixes on the new signer code
Self-review of this session's changes surfaced four issues:

- Perms re-seeded on every re-pair. connectViaNostrConnect pre-granted the
  offer's declared ops outside the first-contact guard, so re-pairing an app
  overwrote per-op decisions the user had since changed (e.g. an op set to DENY
  came back as ALLOW). Now only on first contact.
- Perms could silently grant sensitive ops. The nostrconnect flow shows no
  dialog (scan = consent), so the declared perms are never surfaced — yet seeding
  pre-granted everything except decrypt/deletion, which would silently allow
  config-overwrite (kinds 0/3) and other sensitive kinds. Tightened to only the
  ops REASONABLE already auto-allows, so pairing never exceeds the default policy;
  sensitive kinds still prompt on first use.
- Batched-consent deny-all race. SignerConsentActivity.onDestroy denied every
  pending request when finishing; a request arriving as the sheet closed is owned
  by a freshly-launched instance, so it was wrongly denied. Removed — each
  dialog's onDismissRequest already fails closed, and the 120s bridge timeout
  backs it up.
- Redundant work: batched-selection state keyed on list size (a new request in a
  same-size swap was unselectable) → key on the token set; connected-apps loader
  re-read loadPolicy per app when allPolicies() already carried it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude
1d8b7d7c8f feat(nip46): batched consent via concurrent request dispatch
Third refinement from the Primal comparison — and the one that needed an
architecture change, not just UI.

Quartz: NostrConnectSignerService now fans each request into a child coroutine
under a Semaphore(maxConcurrentHandles=16) instead of handling them inline, so a
request awaiting a consent prompt no longer blocks other clients' auto-allowed
traffic and several prompts can be pending at once. Intake (dedup, staleness,
rate-limit, seen-id persistence) stays on the single consumer. Two guards keep
it safe: BunkerRequestProcessor serializes the actual crypto with a Mutex
(authorization — the prompt — runs unlocked, only sign/encrypt/decrypt holds the
lock) so an external NIP-55 signer never sees concurrent IPC ops; and
Nip46PermissionAuthorizer serializes first-connect consent so two connects can't
stack dialogs. Covered by BunkerRequestProcessorConcurrencyTest (crypto never
overlaps; a blocked prompt doesn't stall another client's signing).

Amethyst: SignerConsentCoordinator is now a shared pending StateFlow; one
SignerConsentActivity observes it and shows the rich single-request dialog (1
pending) or a batched checkbox list with select-all + a Remember toggle +
Allow/Deny selected (>1). Dismissing the sheet denies every still-open request
(fail closed).

Needs on-device validation (burst batching, no concurrent external-signer IPC,
fail-closed on dismiss) — see the device checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude
a039f6adbb feat(nip46): security-icon trust picker + reconnect affordance
Two of the three refinements from the Primal comparison:

Trust picker as security icons: unify the connect dialog and the app-detail
picker on one open→shield→locked glyph set — LockOpen (Full trust, never asks),
Shield (Reasonable), Lock (Paranoid) — replacing the connect dialog's emoji and
the detail's heart so both surfaces read the same and the icon carries the
guard-level at a glance.

Reconnect affordance: when an app's relays show Offline (from the live status
added last commit), offer a one-tap Reconnect — on the connected-apps row and in
the detail's Relays section — that forces the relay pool to re-dial now, ignoring
backoff. Shared Nip46ReconnectPill.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude
98c18a9fcf feat(nip46): honor nostrconnect perms + per-app live relay status
Two gaps found comparing against Primal's NIP-46 signer:

Honor the offer's `perms`: we already parsed the `nostrconnect://?perms=` list
but ignored it. Now the declared ops are pre-granted at pairing (the deliberate
pair is the user's consent for what the app openly asked for), so a client that
declares its needs runs without prompting on first use. The two highest-risk
classes stay gated even when declared — decryption (private content) and
deletion (kind 5) still prompt on first use with full context. Adds
Nip46PermissionAuthorizer.parsePerms + tests.

Per-app live relay status: the connected-apps list shows a Connected/Offline dot
per app (judged on its own nostrconnect relays, or the inbox relays for a
bunker-flow app), and the detail Relays section shows a live dot per relay — so
"which relays is this costing me and are they up right now" is answerable at a
glance. Shared Nip46StatusDot/Nip46LiveStatus/nip46AppOnline helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:35 +00:00
Claude
bb56bf6a73 feat(nip46): show a connected app's relays on its detail screen
The list row's "N relays" count wasn't inspectable, so a user debugging why the
signer holds a background relay connection couldn't see which relays an app
uses. Add a Relays section to the (already tap-through) detail screen listing
each relay URL, with a note that Amethyst keeps a background connection to each
while the app stays connected. Apps that brought no relays of their own (the
bunker flow) show that they ride the account's inbox relays and add no extra
connection.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
e353467b6f feat(nip46): show the connected app's own icon when it declares one
A NIP-46 client's connect metadata can include an image (its app/site icon).
Render it — in a circle, with the Key glyph as the placeholder/error fallback —
for both the connected-app list row and the detail header, via a shared
Nip46AppIcon. We only draw an icon the app itself advertised; we never fetch a
site favicon from the main app, which would bypass Tor and leak the user's IP
(the same reason BrowserIconRegistry captures favicons in the sandbox instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
0734fdb8de fix(nip46): show the same identity in the connected-app list and detail
The dedicated NIP-46 apps list always rendered the client's npub, while the
detail header showed the app's self-declared website (url) when it had one — so
an app that advertised a website looked like a bare pubkey in the list but a
website once opened. Extract one nip46ClientSubtitle(url, clientPubKey) helper
(website host when declared, npub otherwise) and use it in both places so a row
and the screen it opens never disagree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
052459567c feat(nip46): rename "Nostr Signer" to "Remote Signer"
Renames the user-facing title (drawer entry + screen top bar). Search keywords
already include "remote" and "nostr", so discoverability is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
565342d7bf feat(nip46): drawer entry, dedicated apps screen, foreground surfacing, idle prune
Move the Nostr Signer out of Settings into the left drawer's "You" section,
directly under Wallet (and available as a bottom-bar favorite). Removed the
Settings catalog entry.

Give NIP-46 remote-signer clients their own management screen, separate from
the napplet/nsite/browser Connected Apps screen — unlike those, each NIP-46 app
can carry its own relays that the signer keeps subscribed in the background, so
they need distinct visibility (name, npub, relay count, last-used, trust level)
and pruning. The shared Connected Apps screen no longer lists NIP-46 apps.

Auto-forget apps idle for 7+ days on signer start (Nip46PermissionAuthorizer.
pruneIdle), so an app paired once and abandoned stops leaking a background relay
subscription forever. last-used is stamped on connect and every serviced op, so
an app still in use is never pruned.

Surface the consent dialog when Amethyst is backgrounded: a bare startActivity
from the app context is silently dropped by Android 12+ background-activity-launch
restrictions, so the dialog never appeared and the request timed out. Add a
full-screen-intent notification fallback (the same mechanism CallNotifier uses
for incoming calls) on a high-importance channel; it no-ops when the app is
already in the foreground so there's no redundant heads-up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:34 +00:00
Claude
2a1634bb07 feat(nip46): declutter the signer screen — bigger QR, drop noise
- Enlarge the bunker QR to fill the card width (responsive, easier to scan)
  instead of a fixed 232dp.
- Drop the raw bunker:// URI text under the QR — the QR + Copy button convey
  the address; the long hex string was just noise.
- Remove the "Signing as npub1…" line — on your own signer settings screen the
  account is already implied (that clarity belongs on the consent dialogs,
  where it now lives).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:33 +00:00
Claude
6151a2df25 fix(nip46): move allPolicies disk enumeration off the main thread
allPolicies() enumerates the datastore directory and reads each file (blocking
disk IO) but is invoked from Compose LaunchedEffects on the main dispatcher,
tripping StrictMode's DiskReadViolation. Wrap the File listing + reads in
withContext(Dispatchers.IO). Fixes both the NIP-46 signer screen and the
Connected Apps screen callers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude
99f0c61f72 feat(nip46): preview the event as a signed note in the sign dialog
The per-op consent dialog now renders a sign_event/publish request as a real
NoteCompose preview — what the note will actually look like once signed
(text + media + mentions, authored by the signing account) — instead of only
a quoted content snippet. The "Show event" JSON toggle stays as a fallback for
anyone who wants the raw payload.

Reuses the live AccountViewModel via CallSessionBridge (the same handle
CallActivity uses to render app UI from a standalone Activity), builds a
transient unsigned Note with RumorAssembler.assembleRumor + createTempDraftNote
(never persisted/verified — the same path the composer uses to preview an
unsent post), and passes EmptyNav so taps don't navigate out. SignerConsentInfo
carries the EventTemplate; both the NIP-46 bridge and the napplet
buildSignerConsentInfo populate it for Publish/SignEvent.

Falls back to the content quote + JSON when the AccountViewModel isn't
available (main Activity gone) or the op has no event (encrypt/decrypt).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude
4fa41f1a67 feat(nip46): show which account is acting on the consent dialogs
Both signer dialogs now render the account's avatar + display name instead of
a raw pubkey / coordinate hex, so it's clear which logged-in identity is
approving, signing, encrypting, or decrypting:

- Connect dialog: replaces the client transport-pubkey line with the account
  being connected to (avatar + name).
- Per-op dialog: replaces the meaningless coordinate hex with the account that
  would sign/encrypt/decrypt.

The account is resolved from the coordinate's signer pubkey
(Nip46PermissionAuthorizer.signerPubKeyOf) via LocalCache, and rendered with a
shared ConnectedAccountRow (RobohashFallbackAsyncImage + name, robohash
fallback). SignerConnectInfo/SignerConsentInfo carry the account
name/picture/pubkey; the napplet/browser paths leave them null and keep their
existing domain line.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude
00ec826e8b feat(nip46): refuse requests when the account can no longer sign
Reject sign/encrypt/decrypt when the identity signer is not writeable — the
account was logged out, is read-only, or lost its external (NIP-55) signer —
returning an `account unavailable` error instead of prompting the user or
hanging on a key that can't be used. Checked before authorization, so no
dialog is raised for a key we can't sign with. Public reads (get_public_key,
ping, get_relays) stay ungated.

Test: a non-writeable signer refuses a sign request without invoking the
signer or the authorizer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:32 +00:00
Claude
5fa8a0a2aa feat(nip46): persist serviced request ids so a restart never re-signs replays
The 30s window shrank the restart re-sign problem but couldn't close it: a
relay replays stored ephemeral requests on re-subscribe, and the in-memory
dedup set is wiped on restart, so anything within the window came back.

Persist the recently-serviced kind-24133 event ids (bounded to 128) and seed
the service's dedup set from them on start, so a replay after an app restart
is dropped by EXACT event id. Chosen over a created_at high-water mark on
purpose: a global timestamp floor would wrongly drop a second connected app
whose clock lags behind another's, whereas id-matching is immune to client
clock skew. The `since` filter still bounds how far back relays replay.

- AccountSettings.nip46SeenRequestIds (persisted via putStringSet) + host-side
  bounded LinkedHashSet, fed to NostrConnectSignerService.initialSeen and
  advanced through onHandledId.
- Tests: a fresh request whose id was serviced last session is not repeated;
  the serviced id is reported for persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude
9a0af15f36 fix(nip46): tighten stale-request window to 30s
An app restart re-subscribes with `since = now - window`, so relays replay
(and the signer re-signs) anything created within the window. 120s was wide
enough that a request made a minute before restart still came back; 30s keeps
that replay window small while still tolerating normal NTP clock skew.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude
cdf43db0eb fix(nip46): ignore stale/replayed sign requests by age
Users on multiple relays were being asked to sign the same request repeatedly,
some minutes old. Root cause: kind-24133 is ephemeral, but many relays store
and REPLAY it every time the signer re-subscribes (a relay-set change,
reconnect, toggle, or rotation), and the in-memory dedup set is scoped to one
run() call so it's wiped on restart — the old requests then get signed again.

Gate requests by created_at (default 120s window):
- a `since` on the subscription filter so compliant relays never replay old
  stored events, and
- a receive-side staleness drop for relays that ignore `since`.

The window must exceed realistic client/relay clock skew so a genuinely fresh
request is never dropped. Also corrected the seenCap KDoc, which called the
event-id dedup set a "request-id" set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude
e69b59a255 test(nip46): verify a real-world Ditto kind-1 signs through the bunker
Runs the exact payload (kind 1 + `client` tag + fixed created_at) through the
processor/authorizer with a REASONABLE policy and asserts: it signs with no
prompt (kind 1 is auto-allowed), created_at/content/tags are preserved, the
event is authored by the identity key (not the transport key), and the
signature + id verify.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:31 +00:00
Claude
2976afc83b docs(nip46): record audit findings + known limitations in the checklist
Independent review confirmed the first-connect wedge (fixed) and flagged the
inline-consent head-of-line blocking and relay-restart cancellation as
architectural limitations to address with a device-tested subscription/
concurrency redesign, plus three low-severity items. Documented so they are
not lost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude
31cd6c8bb7 fix(nip46): time-out the first-connect consent prompt; drop dead code
Audit fixes:

- requestConnect had no timeout while requestOp did. Since authorize()/
  onConnect() run inline in the signer service's single-consumer loop, an
  ignored first-connect dialog blocked every other client's requests forever.
  Both consent prompts now fail closed on the shared 120s timeout (per-op →
  deny-once, connect → declined) so a stuck dialog can't hold the loop hostage.
- Remove the now-unused Nip46ActivityLog.forClient() (the detail screen filters
  the collected flow instead).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude
5c73a7c1f7 refactor(consent): move the shared signer-consent UI out of napplet
The per-operation consent dialog and the first-connect trust picker are not
napplet-specific — napplets, sandboxed browser origins, and now the NIP-46
remote signer all prompt through them. Rename and relocate them out of the
napplet package into a neutral home so the shared plumbing reads honestly:

  napplet/NappletSignerConsent{Info,Coordinator,Activity,Dialog}
    → connectedApps/consent/SignerConsent{Info,Coordinator,Activity,Dialog}
  napplet/NappletConnect{Info,Coordinator,Activity,Screen}
    → connectedApps/consent/SignerConnect{Info,Coordinator,Activity,Screen}

The genuinely napplet-specific pieces stay put: the capability-consent flow
(NappletConsent*), and the napplet→info builders (buildSignerConsentInfo /
buildConnectInfo, which resolve a napplet manifest identity) now return the
relocated generic render models. Manifest activity names and all references
updated; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:30 +00:00
Claude
ac6697330e test(nip46): consent integration test + device verification checklist (Tier 4)
- Nip46ConsentIntegrationTest: end-to-end through the real dispatch path
  (BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent)
  with a real NostrSignerInternal — proves an ASK sign prompts and returns a
  signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app
  signs even a dangerous kind (0) without prompting.
- Device checklist (amethyst/plans/) for the interactive/background/interop
  behavior JVM tests can't cover: pairing paths, consent variants, rotation,
  activity feed, relay health, boot restart, and the reference-client matrix.

Notification polish was deliberately skipped: the always-on notification is
shared with the relay/DM service, and consent uses its own dialog Activity, so
neither retitling nor notification actions are warranted. Documented in the
checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude
684ce26f63 feat(nip46): surface relay-connection health on the signer screen (Tier 3)
The live status card now reflects whether the signer's listening relays are
actually connected, so silently-missed requests become visible: it reads the
client's connectedRelaysFlow(), intersects with the signer's listening set,
and shows "Listening on N relays, all connected" or "X of N relays connected"
when some are down.

Boot-restart needs no change: the existing BootCompletedReceiver already
restarts the foreground service whenever isEnabled() is true, and that check
honors nip46SignerEnabled — so an enabled signer resurrects after a reboot or
app update.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude
4998d7d460 feat(nip46): tailor the connected-app detail screen for remote signers (Tier 2)
The shared Connected-App detail screen mis-parsed a nip46:<signer>:<client>
coordinate and rendered it through the napplet manifest path (showing a
truncated coordinate). Add a NIP-46 branch that:

- heads the screen with the client's self-declared name + url (from the stored
  Nip46ClientInfo) and a key badge, and titles the top bar with the app name;
- shows that client's recent serviced-request history (reusing the shared
  Nip46ActivityCard, extracted so the signer screen and this screen share it).

The existing trust-level picker, per-op overrides, and NIP-46-aware Forget
action render below unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:29 +00:00
Claude
f9d9691017 feat(nip46): activity feed + account clarity on the signer screen (Tier 2)
Give the user visibility into what the signer is doing:

- Nip46ActivityLog: a bounded, newest-first, in-memory feed of serviced
  requests (method + kind + client + ok/denied), fed from the service's
  onServiced hook (enriched to pass the full BunkerRequest so the event kind
  is available). Survives service restarts; not persisted (it's a live feed).
- The signer screen shows a "Recent activity" card (last 8, friendly labels
  like "Signed an event (kind 1)", green/red status dot, relative time) and a
  "Signing as npub1…" line so it's clear which account is the bunker.

onServiced now hands callers the BunkerRequest instead of just the method
string (CLI updated to match).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude
4676d176ec feat(nip46): live per-request + first-connect consent (Tier 1)
Wire the NIP-46 remote signer into the same interactive consent surface the
napplet/browser signer path uses, so requests that aren't pre-granted prompt
instead of silently failing.

The ledger already returns ASK for the risky operations (profile 0, contacts
3, deletion 5, decryption, DMs are excluded from REASONABLE_SIGN_KINDS; a
PARANOID app asks for everything) — the only reason it didn't work was that
authorize() treated ASK as "unauthorized". Now:

- authorize(): ALLOW proceeds, DENY refused, ASK consults an in-memory session
  grant then calls opConsent (the shared per-op dialog). The returned
  SignerOpGrant is recorded via a new NostrSignerPermissionLedger.record()
  helper (allow-for-op / until / all / deny-for-op persisted; once/session not),
  mirroring the broker. No opConsent wired → ASK fails closed (CLI/tests).
- onConnect(): first contact asks connectConsent for the trust level
  (AppConnectResult) instead of silently granting REASONABLE; Blocked/Cancelled
  reject the connection. Falls back to defaultPolicyOnConnect when no prompt.
- forget() also clears the client's in-memory session grants.

Nip46ConsentBridge (amethyst) implements the two prompts by reusing the
existing NappletConnect/NappletSignerConsent coordinators + dialogs + ledger,
building the render info from the bunker request (op label, event JSON
preview, client metadata/icon). A 120s timeout fails a stuck per-op prompt
closed so it can't wedge the signer's single-consumer loop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude
d8d185b928 feat(nip46): register nostrconnect:// deep link to the signer
Add a VIEW/BROWSABLE intent filter for the `nostrconnect` scheme on
MainActivity so a website's "Connect with Amethyst" link (or any app
firing a nostrconnect:// URI) hands off into the same pairing flow as a
scanned QR. The incoming intent.data already flows through uriToRoute,
which now maps a nostrconnect:// offer to Route.Nip46Signer(connectUri);
both the cold-start and onNewIntent paths navigate there and pair on open.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude
3ed8779fff feat(nip46): scan nostrconnect:// from anywhere and un-gate the connect flow
Two UX gaps made "scan an app's code to connect it to my signer" hard to
reach:

- The signer screen's Connect section (Scan a code / paste) lived behind
  the enabled gate, so a first-time user saw only the "Turn on signer"
  hero and couldn't scan until they had enabled. Since a successful
  nostrconnect pairing already calls setEnabled(true), the gate was
  pointless. The Connect section now shows in both states; the success
  toast spells out the consequence ("Amethyst now signs for it in the
  background").
- The app's general QR scanner (uriToRoute) only understood NIP-19
  entities, so scanning a nostrconnect:// code did nothing. It now routes
  a nostrconnect:// offer to the signer screen, which pairs the app on
  open. Route.Nip46Signer carries an optional connectUri for this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:28 +00:00
Claude
cceac4478a feat(nip46): rotate the bunker transport key as the anti-spam remedy
Make the "regenerate" action mint a brand-new transport keypair (plus a
fresh pairing secret) instead of only rotating the secret, so a spammed
user can burn down the old bunker:// address. Anyone holding the old
address — a spammer included — can no longer reach the signer, and every
app talking to the old transport pubkey is dropped. Legit apps re-pair by
re-scanning; their trust survives because the Connected-Apps coordinate
keys off the stable identity pubkey, not the transport key.

For rotation to actually take effect, the cached `by lazy` transportSigner
is replaced with a per-call rebuild from the persisted key, and the
service-restart trigger now includes AccountSettings.nip46TransportKey so
the running NostrConnectSignerService re-subscribes under the new key.
setEnabled() settles the secret and transport key before flipping the flag
to avoid a throwaway double-start on first enable.

The UI gates rotation behind a confirmation dialog (it disconnects every
connected app) and relabels the action "New address".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:27 +00:00
Claude
2332384897 feat(nip46): use a dedicated transport key so the bunker doesn't reveal the user
NIP-46 lets the remote-signer (transport) key differ from the user's identity
key. Previously the bunker advertised and wrapped everything with the identity
key, so anyone watching the inbox relays could see kind-24133 traffic addressed
to the user's real npub and infer "this npub runs a bunker".

Now each account gets a dedicated, persisted transport keypair:

- NostrConnectSignerService wraps/unwraps the kind-24133 envelope with a
  `transportSigner`; BunkerRequestProcessor keeps the identity signer for the
  actual sign/encrypt/decrypt and answers get_public_key with the real npub
  (disclosed only to a connected client, over the encrypted channel).
- The host mints + persists the transport key lazily (accounts that never
  enable the signer mint nothing), advertises it in bunker:// and the
  nostrconnect ack, and listens p-tagged to it.
- AccountSettings/LocalPreferences persist nip46TransportKey so the advertised
  address stays stable across restarts.

Bonus: because the envelope is now wrapped with a LOCAL key, external NIP-55
(Amber) accounts no longer round-trip the external app for envelope crypto —
only the genuine signing request does. A new test asserts get_public_key
returns the identity, never the transport key. Unreleased feature, so no
migration needed. The CLI bunker keeps using the operator's own key (dev tool).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:27 +00:00
Claude
922a5841d0 fix(nip46): make forgetting a client complete and immediate
Clearing a connected client on logout had two gaps:

- The user-facing "Forget this app" button only revoked the permission ledger;
  it never cleared the NIP-46 client store, so a forgotten app's metadata and
  relays lingered and were re-recovered on the next restart. Route NIP-46
  coordinates through the host's new forgetClient() so the store is cleared too.
- Neither logout path stopped the RUNNING session from listening on the app's
  relays — only the next restart picked up the change. extraRelays is now a live
  projection of the client store (recomputed on connect, on start, and on
  disconnect via a new onDisconnected hook), so a forgotten app's relays are
  dropped immediately.

onLogout and the UI Forget now share one authorizer.forget() path (revoke grant
+ clear store + clear throttle entry + signal the host), so client-initiated and
user-initiated disconnects behave identically. Adds tests for both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude
cd3e1353e7 docs(nip46): correct stale coordinate format in KDoc
Two doc comments still described the pre-namespacing coordinate
`nip46:<clientPubKey>`; the actual key is `nip46:<signerPubKey>:<clientPubKey>`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude
c20cc2b514 refactor: move the generic signer-permission layer out of napplet/
The per-app signing-authorization plumbing was named/located under `napplet/`
for historical reasons, but it is not napplet-specific — it already gates
napplets, the sandboxed browser, and (now) NIP-46 remote clients through one
shared ledger. The package name mislabelled what the code is, so:

- commons: `napplet/signers/` (generic) → `connectedApps/signers/`
  (AppSignerPolicy, NostrOpDecision, NostrSignerOp, NostrSignerConsentPrompt,
  NostrSignerPermissionLedger/Store). The NIP-46-specific bridge moves to
  `connectedApps/nip46/` (Nip46PermissionAuthorizer, Nip46ClientStore), so the
  feature is no longer split across unrelated packages.
- The `NappletRequest.toSignerOp()` extension — napplet protocol leaking into
  the generic layer — moves back to `napplet/protocol/`.
- amethyst: `napplet/DataStoreNostrSignerPermissionStore` → `connectedApps/`,
  `napplet/DataStoreNip46ClientStore` → `connectedApps/nip46/`.

Pure move + repackage: all 27 import sites updated, no behaviour change.
Napplet-specific code (broker, capabilities, consent, :nappletHost) and the
Connected Apps UI folder are untouched — those really are napplet/UI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:26 +00:00
Claude
d06b1c7f2c feat(nip46): rate-limit flooding clients + clean up on logout
Follow-ups to the audit:

- Abuse protection: the signer service now bounds its event queue
  (DROP_LATEST) and rate-limits per author BEFORE decrypting — decryption can
  be an external-signer (NIP-55) IPC round-trip, so a looping or hostile client
  can no longer force one per event or grow the queue without limit. Fixed
  window (default 40 requests / 10s per author, oldest authors evicted). The
  limiter is touched only by the single consumer coroutine, so it needs no
  locking. Covered by a headless test.
- logout now clears the client's persisted metadata/relays too (not just the
  ledger grant), so a disconnected app stops being listened for after restart.

Not changed: get_public_key/ping stay ungated — gating them behind a prior
connect risks breaking clients that discover the pubkey at connect time, and
the pubkey is already public, so the enumeration leak is negligible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude
fe4e881df6 fix(nip46): audit fixes — data race, cancellation, write amplification
Findings from an audit of the signer, all verified against the code:

- Data race: NostrConnectSignerService deduped request ids inside onEvent,
  which the relay pool invokes CONCURRENTLY from each relay's socket thread
  (PoolRequests dispatches listeners outside its lock). Two relays delivering
  the same subscription could mutate the LinkedHashSet at once → race / CME.
  Move dedup into the single consumer coroutine; onEvent now only does the
  thread-safe channel send.
- Swallowed cancellation: broad `catch (Exception)` around suspend calls in the
  processor, the service's decrypt + publish, and connectViaNostrConnect caught
  CancellationException too, breaking structured cancellation when the service
  restarts. Rethrow it first (matching the AccountCacheState convention).
- Write amplification: the ledger wrote last-used to that client's DataStore
  file on EVERY authorized request (unthrottled, unlike the relay-auth store).
  Coalesce to at most one write per client per 60s in the authorizer.
- Redundant resubscribe: the enable/relays collector lacked distinctUntilChanged,
  so a duplicate inbox-relay emission tore the subscription down and re-opened
  it on every relay for nothing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:25 +00:00
Claude
994bdae2d1 feat(amethyst): keep the signer answering in the background
The signer's relay subscription lives in the account scope and needs the shared
NostrClient connected and the process alive to keep working while Amethyst is
backgrounded or closed. Hook it into the existing always-on foreground service
(and its five restart layers) instead of building a new one:

- AlwaysOnNotificationServiceManager now starts/stops the layers when EITHER the
  notification service or nip46SignerEnabled is on (combined flow).
- NotificationRelayService.isEnabled (the auto-restart guard) honors the signer
  flag too, so START_STICKY / watchdog / boot restart keep the signer up.
- The signer screen notes that a background connection (ongoing notification)
  is what lets it answer requests while closed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude
9667168a6a feat: persist connected NIP-46 client metadata + relays
Add a Nip46ClientStore (commons interface + InMemory + a single-file Android
DataStore) keyed by the same signer-namespaced coordinate as the permission
ledger, holding each connected client's self-declared name/url/image and the
relays it reaches us on.

- The host persists metadata on connect (bunker + nostrconnect) and, for the
  nostrconnect flow, the app's own relays. On startup it re-adds those relays
  to the listen set, so a nostrconnect-paired app stays reachable across app
  restarts instead of silently going dark until it re-pairs.
- Connected Apps now shows the app's real name (falling back to the generic
  label + npub) for remote-signer clients.
- Wired the store through AppModules → AccountCacheState → Account → host.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude
dea711596e feat: namespace NIP-46 grants by signer + revoke on logout
The Connected Apps signer store is app-global, so a remote client keyed only by
its own pubkey would share one trust level across every local account. Namespace
the coordinate as `nip46:<signerPubKey>:<clientPubKey>` so the same client paired
with two accounts on one device gets independent grants.

- Nip46PermissionAuthorizer takes the user's signerPubKey; coordinateFor/belongsTo
  encode + match the namespace; clientPubKeyOf reads the trailing segment.
- onLogout now revokes the client's grant (wired through the new quartz hook).
- Connected Apps lists only the active account's remote clients (napplet/browser
  grants stay app-global); the signer screen counts the same way.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:24 +00:00
Claude
d46ac987e8 feat(quartz): harden signer service, add logout + end-to-end loopback test
- NostrConnectSignerService now bounds its request-id dedup set (LinkedHashSet
  with an evicting cap) so a long-lived bunker can't leak memory on the ids it
  has seen.
- Add NIP-46 `logout`: the processor recognises the method, acks it, and calls
  a new Nip46RequestAuthorizer.onLogout hook (default no-op) so a host can
  revoke the app's grant when it disconnects.
- New NostrConnectSignerServiceTest drives full request→reply round trips
  (connect/sign/logout + drop-if-not-addressed) through the service over a fake
  relay client with passthrough signers — headless proof of the subscribe →
  decrypt → dispatch → publish wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:23 +00:00
Claude
9f32f72a41 feat(amethyst): redesign the Nostr Signer screen around a scannable hero
Rework the signer settings screen from a plain toggle list into a purpose-
built pairing surface:

- A disabled-state hero (key medallion, headline, one big "Turn on signer"
  call to action) that reads as a feature intro rather than a setting.
- A live status card with an animated pulsing dot summarising "signing for N
  apps · listening on M relays".
- A QR hero: the bunker:// address rendered as a large scannable QR
  (QrCodeDrawer) on a white surface — pairing is scan-first, with copy and
  new-secret as tonal actions and the raw string kept as a caption.
- Scan-to-connect: a primary "Scan a code" button opening the QR scanner for
  nostrconnect:// offers, with paste-a-link as a revealable fallback.
- A connected-apps row showing the live count and linking into Connected Apps.

Reuses the existing QrCodeDrawer / SimpleQrCodeScanner composables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:22 +00:00
Claude
c3a1762637 feat(amethyst): act as a NIP-46 signer for other apps
Adds the "Nostr Signer" feature: Amethyst can now be a remote signer (a
"bunker") for other apps, listening on the user's inbox relays and signing
through whatever signer the account uses — a local key or a NIP-55 external
app — gated by the shared Connected Apps trust ledger.

- Nip46SignerState: account-scoped host that runs the quartz
  NostrConnectSignerService on the inbox relays whenever the feature is on,
  restarting on relay/toggle changes. Builds the bunker:// advertisement,
  handles nostrconnect:// paste pairing, and manages the pairing secret.
  Requests are authorized through Nip46PermissionAuthorizer (the Connected
  Apps ledger), so a remote client is a connected app under nip46:<pubkey>.
- AccountSettings: persisted nip46SignerEnabled toggle + nip46BunkerSecret,
  wired through LocalPreferences.
- Account/AccountCacheState/AppModules: build the signer ledger from the
  app-global Connected Apps store and construct the host per account.
- UI: a Nostr Signer settings screen (enable toggle, listening status,
  copyable bunker address with secret regeneration, nostrconnect:// connect
  box, link to Connected Apps) + settings-catalog entry + route. Connected
  Apps renders nip46: clients as remote-signer cards with their trust chip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:21 +00:00
Claude
81aea57ddb feat(commons): ledger-backed NIP-46 authorizer for Connected Apps
Nip46PermissionAuthorizer implements the quartz Nip46RequestAuthorizer by
routing every remote-signer request through the shared Connected Apps
permission ledger (NostrSignerPermissionLedger). A NIP-46 client becomes a
connected app under the coordinate `nip46:<clientPubKey>`, so it reuses the
same per-app trust levels and per-op overrides as napplets and web origins:

- sign/encrypt/decrypt requests map to NostrSignerOp and are allowed only when
  the ledger's standing decision is ALLOW (ASK/DENY are refused — a background
  signer cannot prompt, so access is granted ahead of time in the UI).
- connect validates the pairing secret, then registers the app at a default
  REASONABLE policy (never downgrading a level the user already set) and echoes
  the secret back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude
dc0840264d refactor(cli): drive amy bunker through the shared quartz signer core
Replace BunkerCommand's hand-rolled subscribe/decrypt/dispatch/publish loop
with NostrConnectSignerService + BunkerRequestProcessor, and route bunker://
/ nostrconnect:// URI parsing+building through NostrConnectURI. Behaviour is
unchanged (the CLI bunker still hosts the operator's own key and auto-approves
every request via a small CliAuthorizer that only checks the pairing secret);
this removes the duplicated protocol logic now that it lives in quartz.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
Claude
b1227fa7cb feat(quartz): add signer-side NIP-46 processor, service and URI codec
Adds the bunker/signer half of NIP-46 as reusable, signer-agnostic quartz
components so Amethyst can act as a remote signer for other apps:

- BunkerRequestProcessor: turns a decrypted BunkerRequest into the
  BunkerResponse the client expects, performing the work through whatever
  NostrSigner the account uses (local keypair or NIP-55 external app).
  Signing/encryption/decryption are gated through a Nip46RequestAuthorizer;
  public reads (get_public_key/ping/get_relays) are not.
- Nip46RequestAuthorizer: the permission boundary the host app plugs its
  own trust model into (connect validation + per-op authorization).
- NostrConnectSignerService: subscribes to kind-24133 requests on a relay
  set, decrypts, dispatches to the processor, and publishes the reply.
- NostrConnectURI: KMP-safe parse/build for bunker:// and nostrconnect://
  pairing URIs (percent-encoded), shared by CLI/desktop/Android.

Unit tests cover the dispatch/authorization matrix with a fake signer (no
crypto) and the URI round-trips.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
2026-07-17 15:18:20 +00:00
m
a1b7cad85c feat(compose): wire NIP-05 popover mentions to nostr:nprofile1…
When the author types a NIP-05 mention (full `m@testls.bit` form or bare
`testls.bit` domain), the existing user-suggestion popover already resolves it
asynchronously on Dispatchers.IO via Nip05Client (Namecoin for .bit, regular
.well-known/nostr.json for everything else). On pick, however, the inserted
token was `@npub1…`, losing the relay hints already harvested during
resolution.

Insert `nostr:${user.toNProfile()} ` for NIP-05-shaped picks so:

  * the send-time NewMessageTagger parses the bech32 inline through its
    existing `nprofile1` branch — no extra main-thread I/O, no per-word
    network round-trips that could compound into 30s+ stalls on dead servers;
  * relay hints harvested by nip05ResolutionFlow ride along in the nprofile;
  * the email-vs-mention call is the author's: typing `vitor@vitorpamplona.com`
    surfaces the suggestion but only commits as a mention when the author taps
    the row. Just typing the address sends as plain text.

Non-NIP-05 picks (search by name, typed npub/nprofile, hex pubkey) keep the
existing `@npub1…` insertion form.

Also collapses the open-coded `Nip05Id("_", prefix.lowercase())`
bare-domain synthesis onto Nip05Id.parseLenient so there's one place doing
that conversion.

Addresses review feedback on #3165: avoids the extra parser pass and the
inline send-time resolve loop in NewMessageTagger by routing all NIP-05
mentions through the popover the author already uses.
2026-07-11 20:35:16 +10:00
m
6ac8e475df feat(nip05): add Nip05Id.parseLenient for mention/text rendering
Add a small companion helper that lets callers parse either:
  - a full NIP-05 identifier (`name@domain.tld`), same as Nip05Id.parse; or
  - a bare domain (`domain.tld`), synthesized as the wildcard form
    `_@domain.tld` per NIP-05.

This is the building block used by upcoming mention parsing in note
compose (NewMessageTagger) and note rendering (RichTextParser) so that
typing `@m@testls.bit` or `@testls.bit` in a note tags the user
correctly, and a received note that contains `m@testls.bit` becomes a
clickable user link instead of falling into the email/mailto bucket.

The helper is intentionally split out from the strict `parse`
(unchanged) so its lenient behaviour (bare-domain synthesis) cannot
sneak into NIP-05 verification flows that need the strict form.
2026-07-11 20:35:16 +10:00
672 changed files with 50566 additions and 10142 deletions

View File

@@ -302,6 +302,21 @@ Do this before considering the task complete.
`forEach`/`map`/`filter`/`any` — the `fast*` variants allocate no iterator,
no intermediate list, and no lambda object. Don't "modernize" those into
stdlib collection calls; match the surrounding hot-path style.
- **Never put raw invisible/bidirectional Unicode characters in source files**
— write them as `\uXXXX` escapes instead (`'\u202E'`, `Regex("[\u200B-\u200D\uFEFF]")`).
This covers the bidi family Sonar's Trojan-Source rule (CVE-2021-42574)
flags — U+202AU+202E, the isolates U+2066U+2069, U+200E/U+200F, U+061C —
plus zero-width characters (U+200BU+200D, U+FEFF, U+2060). The escape
compiles to the identical codepoint, so behaviour is unchanged; the point is
that the file on disk stays visually unambiguous. Applies even when the
character is *intentional* (sanitizer strip-lists, adversarial test
payloads) — that's data, and escapes express it just as well. Exceptions:
U+200D as part of a real emoji ZWJ sequence in test data (👩‍👧 — functional,
not a bidi control), and LRM/RLM inside Crowdin-managed `strings.xml`
translations (legitimate RTL typography; don't touch those files by hand
anyway). Note the tooling trap: the Edit tool may normalise a typed
`\uXXXX` back into the raw character — if that happens, do the replacement
at byte level (`perl -CSD -pe 's/\x{202E}/\\u202E/g'`).
### Navigation Shell
- **Desktop**: Sidebar + main content area

View File

@@ -7,7 +7,9 @@ description: Use when comparing Android strings.xml locale files to find untrans
## Overview
Extract string resource keys from the default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
Extract string resource keys from a default `values/strings.xml` that are absent in a target locale's `strings.xml`, excluding non-translatable entries. Outputs missing keys and offers to translate them.
The repo now has **two independent Crowdin-managed resource trees** — you must scan **both** (see "Resource trees" below).
## When to Use
@@ -15,6 +17,56 @@ Extract string resource keys from the default `values/strings.xml` that are abse
- Preparing a batch of strings for a translator
- Checking translation coverage after adding new features
## Resource trees (scan BOTH)
There are two separate `strings.xml` trees, each with its own default `values/` and per-locale `values-<locale>/` files, each wired into `crowdin.yml` independently:
| Tree | Default file | Per-locale file |
|------|--------------|-----------------|
| **amethyst** (Android app) | `amethyst/src/main/res/values/strings.xml` | `amethyst/src/main/res/values-<locale>/strings.xml` |
| **commons** (KMP Compose resources, shared by Android + Desktop) | `commons/src/commonMain/composeResources/values/strings.xml` | `commons/src/commonMain/composeResources/values-<locale>/strings.xml` |
The `commons` tree appeared when shared event-renderer composables were extracted out of `amethyst/` into `commons/` (Compose Multiplatform `stringResource`). It is **not** a copy of the amethyst tree — the vast majority of its keys are commons-only; only a small handful overlap. Every diff/count/translate command below works on either tree by swapping the base path — **run the whole technique once per tree** and report them separately (each maps to its own Crowdin file, so the counts should reconcile against two different Crowdin UI numbers).
**Locale-qualifier caveat:** `commons` uses the same region-qualified locale dirs as amethyst for our four targets (`values-cs`, `values-de-rDE`, `values-sv-rSE`, `values-pt-rBR`), but the *full* set of locale dirs differs between trees. Enumerate `values-*` under each tree's own base rather than assuming they match.
**Overlap (copy — but only after checking the English matches):** a few `commons` keys share a *name* with a key in the amethyst tree. For such a key already translated in the amethyst locale file you may **copy the existing approved translation verbatim** — but **only if the two English source values are byte-identical.** A shared key name does **not** guarantee a shared meaning.
> ⚠️ **Mistake we actually made (2026-07-18):** `napplet_card_permissions` exists in *both* trees with the *same key name* but *different English* — commons = `"What it can access"`, amethyst = `"Permissions:"`. Copying the amethyst translation by key name produced the wrong string in commons (it said "Permissions:" where the UI reads "What it can access"). **Always diff the English values, not just the key names.** When the English differs, translate the commons value fresh — or, better, find the amethyst key whose *value* matches (here `favorite_app_access_show` = "What it can access") and copy *that* approved translation.
Detect name-overlap **and flag value mismatches** in one pass:
```bash
cdef=commons/src/commonMain/composeResources/values/strings.xml
adef=amethyst/src/main/res/values/strings.xml
comm -12 \
<(grep '<string name=' "$cdef" | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
<(grep '<string name=' "$adef" | grep -v 'translatable="false"' | sed 's/.*name="\([^"]*\)".*/\1/' | sort -u) \
| while read -r k; do
cv=$(grep -m1 "name=\"$k\"" "$cdef" | sed 's/.*>\(.*\)<\/string>/\1/')
av=$(grep -m1 "name=\"$k\"" "$adef" | sed 's/.*>\(.*\)<\/string>/\1/')
[ "$cv" = "$av" ] && echo "SAFE-COPY $k" || echo "VALUE-DIFFERS $k commons=\"$cv\" amethyst=\"$av\""
done
```
Only `SAFE-COPY` keys may be copied verbatim. For `VALUE-DIFFERS`, translate the commons English fresh (or copy from the amethyst key that has the *matching value*).
**Whitespace-quote convention differs between trees.** Android string resources use surrounding double-quotes to preserve leading/trailing whitespace (`"replying to "`). The **commons Compose-resources tree does NOT use this convention** — it authors trailing/leading spaces raw and unquoted (`replying to `). So when copying/translating a commons string with edge whitespace, **match the commons source: raw spaces, no wrapping quotes.** (Mistake we made: we copied amethyst's quoted `"replying to "` into commons, where the quotes would render literally.) A quick check for stray quote-wrapping you introduced:
```bash
grep -nE '<string name="[^"]*">"' commons/src/commonMain/composeResources/values-*/strings.xml
# The commons English tree has zero quote-wrapped values — any hit in a locale file is almost certainly a bad copy from amethyst.
```
**Why two catalogs exist — the duplication is NOT a bug to "fix" (don't ask again).** You will see the same English text (`Cancel`, `Save`, `Delete`, `Open`, …) defined *many* times across the amethyst tree under per-feature keys **and** once more in commons under generic keys (`action_cancel`, `action_save`, …). This is **required architecture, not an error:**
- The two trees are **different resource systems**: amethyst uses Android `R.string`; commons uses Compose-Multiplatform `Res.string` (`com.vitorpamplona.amethyst.commons.resources.Res`).
- **`commons` cannot depend on `amethyst`** (amethyst depends on commons — the reverse would be circular). So a composable extracted *into* commons physically cannot reference `R.string.cancel`; it needs its own string, hence the generic `action_*` keys. That is the only way an extracted shared composable can render "Cancel."
- The scattered amethyst per-feature duplicates (`nip46_signer_cancel`, `nest_create_cancel`, …) are **pre-existing tech debt**; the commons keys did not create them.
- Both catalogs are Crowdin-managed **independently**, and Crowdin's translation memory pre-fills repeats, so translating the same word in both trees is **not** wasted effort.
**Do not** treat the value-overlap as something to deduplicate during a translation pass. Migrating amethyst's own screens onto the shared `action_*` strings is a *separate, optional* refactor and a maintainer call — out of scope for this skill. Just translate each tree correctly and independently.
## Background: Crowdin strip-identical behavior
This repo syncs translations via Crowdin (branch `l10n_crowdin_translations`). Crowdin's default export behavior **omits any translation that exactly equals the source**, so a key that the translator deliberately kept as English (common for brand terms like `"Nowhere Drop"`, single-word loanwords like `"Apps"` / `"Feed"` / `"Issues"`, or version prefixes like `"v%1$s"`) will not appear in the locale's `strings.xml` even though the Crowdin UI shows it as 100% translated.
@@ -53,9 +105,25 @@ The default set of locales (unless the user specifies otherwise):
### 1. Identify files
Do this for **each** resource tree (see "Resource trees" above). The examples below use the amethyst base path; repeat every step with the commons base path swapped in.
```
# amethyst tree
Default: amethyst/src/main/res/values/strings.xml
Target: amethyst/src/main/res/values-<locale>/strings.xml
# commons tree
Default: commons/src/commonMain/composeResources/values/strings.xml
Target: commons/src/commonMain/composeResources/values-<locale>/strings.xml
```
A convenient way to run the whole technique twice is to loop over the two base dirs:
```bash
for base in amethyst/src/main/res commons/src/commonMain/composeResources; do
echo "########## tree: $base ##########"
# ... run the diff/count/value-extraction commands with $base/values[...] ...
done
```
### 2. Find missing keys using cs as reference
@@ -153,8 +221,10 @@ Flag and offer to fix:
```bash
# Scan every locale's strings.xml for <item quantity="one"> entries that
# hardcode "1" (or other literal digits) instead of using a placeholder.
# Looks at default + all values-* locales.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
# Looks at default + all values-* locales, in BOTH resource trees.
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
awk -v file="$f" '
/<plurals/ { in_plurals = 1; name = $0; sub(/.*name="/, "", name); sub(/".*/, "", name) }
in_plurals && /quantity="one"/ {
@@ -173,7 +243,9 @@ done
Then scan for dead `quantity="zero"` entries. CLDR's `zero` category is integer-bearing only in **Arabic (`ar`)** and **Welsh (`cy`)**. In every other locale, count=0 falls through to `other`, so a `<item quantity="zero">` entry is dead and likely a translator/author bug (or it silently never fires):
```bash
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml; do
for f in amethyst/src/main/res/values/strings.xml amethyst/src/main/res/values-*/strings.xml \
commons/src/commonMain/composeResources/values/strings.xml \
commons/src/commonMain/composeResources/values-*/strings.xml; do
# Skip Arabic and Welsh — they natively use the zero category.
case "$f" in
*values-ar*|*values-cy*) continue ;;
@@ -260,14 +332,48 @@ When adding translated strings to locale files:
- **Append new strings at the bottom** of the file, just before the closing `</resources>` tag.
- Do NOT try to insert them in alphabetical or matching order — a separate process handles ordering.
- **Insert into each locale ONLY the keys missing from *that* locale — never a shared "union" block.** Because Crowdin strips keys asymmetrically (Step 2), a key you translate may already exist in some target locales. If you compute one union set of missing keys, translate it, and paste the *same* block into every locale, you will create **duplicate keys** in whichever locales already had them. Drive the insertion off the **per-locale** diff, not the union:
```bash
# For each locale, insert only the keys comm -23 reports missing FOR THAT LOCALE.
for l in cs de-rDE sv-rSE pt-rBR; do
missing=$(comm -23 \
<(grep '<string name=' $base/values/strings.xml | grep -v 'translatable="false"' \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort) \
<(grep '<string name=' $base/values-$l/strings.xml \
| sed 's/.*name="\([^"]*\)".*/\1/' | sort))
# ... append ONLY the $missing keys' translations to values-$l/strings.xml ...
done
```
(This bit us on 2026-07-21: `ps1_save_block`, `podcast_value_for_value`, and `chats_history_relays` were each missing in only *some* commons locales, but the same 3-key block was pasted into all four — producing duplicates in the locales that already had them.)
- **After inserting, verify each edited file has no duplicate keys AND is well-formed XML — before you call the task done.** A duplicate key is not a warning: the `commons` tree's Compose-resources build task fails hard on it (`convertXmlValueResourcesForCommonMain: … Duplicated key '…'`), which breaks the build for everyone. Quick post-insertion gate over every file you touched:
```bash
for f in <every edited strings.xml>; do
dups=$(grep -oE '<(string|plurals) name="[^"]*"' "$f" \
| sed 's/.*name="\([^"]*\)"/\1/' | sort | uniq -d)
[ -n "$dups" ] && echo "DUP in $f: $dups"
python3 -c "import xml.dom.minidom; xml.dom.minidom.parse('$f')" \
|| echo "MALFORMED $f"
done
# For a commons change, also run the build task that enforces this:
# ./gradlew :commons:convertXmlValueResourcesForCommonMain
```
## Common Mistakes
- **Scanning only the amethyst tree** — there are now **two** Crowdin-managed `strings.xml` trees (`amethyst/src/main/res` and `commons/src/commonMain/composeResources`). A key extracted into `commons/` will never show up in the amethyst diff. Run the whole technique once per tree (see "Resource trees") and report each separately.
- **Copying an overlapping `commons` translation by key name alone** — a shared key name does NOT mean shared English. `napplet_card_permissions` is "What it can access" in commons but "Permissions:" in amethyst; copying by name produced the wrong string. Diff the English *values* first; copy verbatim only when they're byte-identical, else translate fresh (see "Overlap" in Resource trees).
- **Applying amethyst's `"…"` whitespace-quote convention to a commons string** — the commons Compose-resources tree authors edge whitespace raw and unquoted; wrapping quotes copied from amethyst render literally there. Match the commons source format.
- **Trying to "dedupe" the amethyst↔commons value-overlap** — it's required architecture (commons can't depend on amethyst, so shared composables need their own `Res.string` catalog), not an error. Don't fold consolidation into a translation pass.
- **Forgetting `translatable="false"`** — these should never appear in 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** — 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.
- **Pasting the union set of missing keys into every locale → duplicate keys** — the union is the right set to *translate*, but the wrong set to *insert*. A key missing in only some locales, inserted into all of them, duplicates in the ones that already had it. Drive each file's insertion off its own per-locale diff (see Step 6). In `commons`, a duplicate key is build-breaking: `convertXmlValueResourcesForCommonMain` fails with `Duplicated key '…'`. **Always run the post-insertion duplicate + XML-wellformedness gate in Step 6 before declaring done.** (Happened 2026-07-21 with `ps1_save_block` / `podcast_value_for_value` / `chats_history_relays`.)
- **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`)

View File

@@ -41,6 +41,15 @@ jobs:
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
# crowdin/github-action runs in a Docker container as root, so any file or
# directory it downloads (especially a brand-new locale folder like
# values-en-rGB/) ends up owned by root. The unprivileged runner user in
# the create-pull-request step below then can't unlink those files, which
# aborts its branch checkout with "unable to unlink ... Permission denied".
# Reclaim ownership of the whole working tree before touching git.
- name: Fix ownership after Crowdin Docker action
run: sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# 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
@@ -61,6 +70,7 @@ jobs:
branch: l10n_crowdin_translations
add-paths: |
amethyst/src/main/res/**/strings.xml
commons/src/commonMain/composeResources/**/strings.xml
docs/changelog/translators.json
commit-message: 'chore: sync Crowdin translations and seed translator npub placeholders'
title: 'New Crowdin Translations'

View File

@@ -17,7 +17,7 @@ Join the social network you control.
[![Maven Central](https://img.shields.io/maven-central/v/com.vitorpamplona.quartz/quartz?label=Quartz%20%28Maven%20Central%29&labelColor=27303D&color=0877d2)](https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz)
[![JitPack snapshots](https://img.shields.io/badge/Quartz%20snapshots-JitPack-27303D?labelColor=27303D&color=0877d2)](https://jitpack.io/#vitorpamplona/amethyst)
[![CI](https://img.shields.io/github/actions/workflow/status/vitorpamplona/amethyst/build.yml?labelColor=27303D)](https://github.com/vitorpamplona/amethyst/actions/workflows/build.yml)
[![License: Apache-2.0](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![License: MIT](https://img.shields.io/github/license/vitorpamplona/amethyst?labelColor=27303D&color=0877d2)](/LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/vitorpamplona/amethyst)
## Download and Install

View File

@@ -24,7 +24,9 @@ Build customized Amethyst Nostr clients for Android. Fork, rebrand, customize, a
2. **Android SDK**
- Command-line tools from https://developer.android.com/studio#command-line-tools-only
- Required components: build-tools, platform-tools, platforms;android-35
- Required components: build-tools, platform-tools, platforms;android-37
- The exact SDK level is `android-compileSdk` in `gradle/libs.versions.toml` —
check there if this number has drifted.
3. **Git** for cloning the repository
@@ -66,48 +68,54 @@ keyPassword=your-password
### 3. Configure Signing
Add to `amethyst/build.gradle` inside the `android {}` block:
Add to `amethyst/build.gradle.kts` inside the `android {}` block:
```gradle
def keystorePropertiesFile = rootProject.file("keystore.properties")
def keystoreProperties = new Properties()
```kotlin
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
keystorePropertiesFile.inputStream().use { keystoreProperties.load(it) }
}
signingConfigs {
release {
create("release") {
if (keystorePropertiesFile.exists()) {
storeFile rootProject.file(keystoreProperties['storeFile'])
storePassword keystoreProperties['storePassword']
keyAlias keystoreProperties['keyAlias']
keyPassword keystoreProperties['keyPassword']
storeFile = rootProject.file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
```
This needs `import java.util.Properties` at the top of the file.
Update the release buildType to use the signing config:
```gradle
```kotlin
buildTypes {
release {
signingConfig signingConfigs.release
getByName("release") {
signingConfig = signingConfigs.getByName("release")
// ... existing config
}
}
```
Verify with `./gradlew :amethyst:signingReport` — the release variants should
report your keystore rather than `~/.android/debug.keystore`.
### 4. Disable Google Services (Required for F-Droid)
**⚠️ CRITICAL:** The Google Services plugin fails when you change the package name. For F-Droid builds, disable it.
Edit `amethyst/build.gradle`, comment out the plugin:
```gradle
Edit `amethyst/build.gradle.kts`, comment out the plugin:
```kotlin
plugins {
alias(libs.plugins.androidApplication)
alias(libs.plugins.jetbrainsKotlinAndroid)
// alias(libs.plugins.googleServices) // DISABLED for F-Droid
alias(libs.plugins.jetbrainsComposeCompiler)
alias(libs.plugins.serialization)
alias(libs.plugins.googleKsp)
}
```
@@ -141,8 +149,8 @@ Edit `amethyst/src/main/res/values/strings.xml`:
### Change Package ID
Edit `amethyst/build.gradle`:
```gradle
Edit `amethyst/build.gradle.kts`:
```kotlin
android {
defaultConfig {
applicationId = "com.yourcompany.yourapp"
@@ -152,8 +160,8 @@ android {
### Change Project Name
Edit `settings.gradle`:
```gradle
Edit `settings.gradle.kts`:
```kotlin
rootProject.name = "YourAppName"
```
@@ -167,36 +175,28 @@ Replace icon files in:
Make your app identify itself on posts with `["client", "YourAppName"]`.
**1. Create tag builder extension:**
You do **not** need to add the tag per event type. The client tag is applied
centrally by `NostrSignerWithClientTag`, a signer decorator that appends the tag
to everything it signs (and respects the user's "add client tag" privacy
setting). Changing the name is a one-constant edit:
Create `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/clientTag/TagArrayBuilderExt.kt`:
Edit `amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt`:
```kotlin
package com.vitorpamplona.quartz.nip01Core.tags.clientTag
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
fun <T : Event> TagArrayBuilder<T>.client(clientName: String) =
addUnique(arrayOf(ClientTag.TAG_NAME, clientName))
const val CLIENT_TAG_NAME = "YourAppName"
```
**2. Add to TextNoteEvent:**
That constant is passed to `NostrSignerWithClientTag` when the account's signer
is built, so every signed event carries your name.
Edit `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/TextNoteEvent.kt`:
Add import:
```kotlin
import com.vitorpamplona.quartz.nip01Core.tags.clientTag.client
```
In both `build()` functions, add after `alt(...)`:
```kotlin
client("YourAppName")
```
The tag itself lives in
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip89AppHandlers/clientTag/`
(`ClientTag`, `TagArrayBuilderExt`, `NostrSignerWithClientTag`) — you only need to
touch it if you want the optional NIP-89 handler address / relay hint variants.
### Modify Default Relays
Edit relay configuration in `quartz/src/main/java/com/vitorpamplona/quartz/nip01Core/relay/` or the UI settings files.
Edit `commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/defaults/Constants.kt`
(see also `AmethystDefaults.kt` and `DefaultDmIndexerRelays.kt` in the same folder).
## Troubleshooting

View File

@@ -391,6 +391,10 @@ dependencies {
implementation(project(":commons"))
implementation(project(":nestsClient"))
implementation(project(":nappletHost"))
// Compose Multiplatform resources runtime, so app-side screens that share a
// string with a commons renderer can read commons' generated `Res` directly
// instead of duplicating the key in the Android res tree.
implementation(libs.jetbrains.compose.components.resources)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
@@ -563,6 +567,7 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.mockk)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.secp256k1.kmp.jni.jvm)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,184 @@
# NIP-46 Signer — device verification checklist
Everything below is behavior that JVM unit tests **cannot** exercise: interactive
consent dialogs, the foreground service, real relay traffic, deep links, and
cross-app interop. The protocol/authorization logic underneath is covered by
`quartz` (`NostrConnectSignerServiceTest`) and `commons`
(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this
list is the manual pass that earns "first-class" on a real device.
Run as the signer on one device/account ("bunker"); use a second app/account as
the client.
## Pairing
- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the
`bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second
Amethyst via `amy login bunker://…`). Client resolves your npub via
`get_public_key`.
- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a
code" on the signer screen pairs it and the signer turns on.
- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that
carries `perms=` shows a connect sheet listing the app's requested
permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust
picker BEFORE anything is granted. Approving pre-grants exactly those ops
(unless Paranoid); Cancel/Block declines and nothing is registered. A
re-pair of a known app skips the sheet and keeps prior decisions.
Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt`
prints an offer that carries exactly those perms (see CLI interop driver).
- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search
camera → lands on the signer screen and pairs.
- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst
opens the signer screen and pairs (cold start AND already-running).
## Note preview in the sign dialog (device-only)
- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose
preview** (text + media + mentions, authored by the signing account), with
the "Show event" JSON toggle still available below it.
- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent.
- [ ] When the main Activity is gone (app fully backgrounded, only the signer
foreground service alive → `CallSessionBridge.accountViewModel` is null),
the dialog falls back to the plain content quote + JSON without crashing.
- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone
dialog Activity; if it reads a CompositionLocal only provided by the main
scaffold it could crash at runtime (compiles fine). Verify on device; if it
misbehaves, the JSON fallback path is one boolean away.
## Entry point + connected-apps management (2026-07-16)
- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly
under Wallet (moved out of Settings). It's also available as a bottom-bar favorite.
- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a
NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the
napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there.
- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer
start (its background relay subscription goes with it); an app still signing is kept.
## Comes-to-front on a request (2026-07-16)
- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client
signing/connect request pops the consent dialog — via a full-screen-intent notification
on the high-importance "Signing requests" channel (the `startActivity` fast path is
BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog;
while actively on another app it shows a heads-up prompt to tap.
- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra
notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`).
- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps,
so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still
arrives and is tappable. Requires notification permission (already needed for the
always-on service).
## Consent (Tier 1)
- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret
shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any
signing; choosing a level records it in Connected Apps.
- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no
silent grant).
- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a
**kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog
appears (these are excluded from the auto-allowed set).
- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session"
stops until the signer restarts; "24h/30d" expire; "deny for op" sticks.
- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts.
- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails
closed (deny) and the signer keeps serving later requests (not wedged).
## Anti-spam rotation (already shipped)
- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps
drop, QR updates; re-pairing a legit app keeps its trust level.
## Visibility (Tier 2)
- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed
(signed kind N / encrypted / decrypted / shared pubkey, green/red dot,
relative time), and per-app history on the Connected-App detail screen.
- [ ] The Connected-App detail screen for a remote client shows its name/url,
not a raw `nip46:` coordinate.
## Reliability (Tier 3)
- [ ] **Relay health**: kill connectivity → status shows "X of N relays
connected"; restore → "all connected".
- [ ] **Boot restart**: enable the signer, reboot the device → the foreground
service comes back and the signer answers a request without reopening the
app. (Same for an app update via `MY_PACKAGE_REPLACED`.)
- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets
serviced (may lag by a relay reconnect).
## Interop matrix
Pair + sign + nip44 encrypt/decrypt + logout against each:
- [ ] nsec.app
- [ ] Coracle
- [ ] Nostrudel
- [ ] snort / other NIP-46 client
## CLI interop driver (`amy`) — added 2026-07-17
The `cli` module (`amy`) drives the same quartz/commons code, so it plays either
side of every NIP-46 flow for reproducible interop tests without a second phone.
All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no
protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block).
Amy as the **client** (Amethyst is the signer):
- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then
every `amy` signing verb routes through it. Surfaces `auth_url` challenges to
stderr, so it completes even when Amethyst defers consent.
- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an
offer for Amethyst to scan. `--perms` is what exercises the app's
**informed-consent** sheet (the offer carries the declared ops).
Amy as the **signer** (Amethyst, or any client, is the client):
- `amy bunker` — headless auto-approve signer for the operator's own key.
- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows
only the listed ops, **rejects** the rest. Use to test how the app-as-client
handles a signer that says no.
- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on
the terminal (TTY-only, default-deny, prompts serialized). Composes with
`--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable"
policy on the CLI).
## Audit findings — known limitations (2026-07-16)
An adversarial review of the signer logic surfaced these. The head-of-line
issues below share one root cause: `authorize()`/`onConnect()` run **inline** in
`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart
that loop via `collectLatest`.
- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect`
now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored
first-connect dialog can no longer wedge the loop forever.
- **FIXED — consent no longer blocks other clients (needs on-device
validation).** The service now fans each request out into a child coroutine
under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on
the single consumer, only `handle()` runs concurrently. So a request awaiting a
prompt no longer stalls auto-allowed traffic, and several prompts can be pending
at once. Two guards keep this safe: (1) the identity signer's crypto is
serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt)
runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external
NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is
serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack
dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending`
flow drives one dialog (1 pending) or a checkbox list (>1). Covered by
`BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need
a real run:
- [ ] **Burst batching:** a client fires several dangerous-kind requests at once
→ one batched sheet with checkboxes + select-all, Allow/Deny selected,
"Remember" toggle. Approving a subset leaves the rest pending.
- [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE
auto-allowed request from another app still gets signed and answered.
- [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two
approved requests do not drive overlapping IPC (they serialize).
- [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every
still-open request (not just the selected ones).
- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect
pairing) mutates the listen set → `collectLatest` restarts the service →
cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost
but the client is leaving; a pairing-time cancel makes other clients retry).
Proper fix: manage subscriptions incrementally (diff add/remove) instead of a
full restart. Deferred (same reason).
- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per
serviced request (negligible under rate-limiting); the per-author rate limiter
evicts by insertion order rather than LRU (the 3-arg `accessOrder`
`LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint
is unsynchronized (practically serialized on the UI thread).
## Deliberately NOT changed
The always-on foreground **notification** was left as-is: it is shared with the
relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking
it to the signer screen would be wrong when the service is up for another reason.
Interactive consent uses its own dedicated dialog Activity, so it needs no
notification actions.

View File

@@ -0,0 +1,201 @@
# NIP-29 group-chat subscriptions: split *state* (always-on) from *content* (paginated)
**Status:** proposed · **Date:** 2026-07-18 · **Module:** `amethyst` (+ `commons` model, reuses `commons`/`quartz` paging)
## Problem
NIP-29 relay-group ("RelayGroup") chat is served today by **six** overlapping
REQ assemblers, each keyed differently and each re-deriving the same two queries:
| Query shape | Emitted by (today) |
|---|---|
| Metadata `#d` (3900039005 + pins) | Warmup, ChannelPublic (open), MyJoinedGroups (roster subset), OnRelay (directory) |
| Content `#h` (kind 9 + poll) | MyJoinedGroups (limit 50), Warmup (limit 50), ChannelPublic-open (limit 200) |
| My-own `#h` (`authors=[me]`) | ChannelFromUser — **redundant for groups** (the all-authors `#h` window already returns my messages; a group is pinned to one host relay) |
| Threads `#h` (11 + 1111) | Warmup, ThreadFeed |
Two concrete defects fall out of this shape:
1. **Slow / partial first load** (the reported bug). Content is fetched in **fixed
windows** (limit 50 / 200) gated by a *shared per-relay* `since`
(`RelayGroupMyJoinedGroupsSubAssembler` is keyed by `Account`, so its `since`
collapses to one map per relay, not per group). A group joined or surfaced
after that relay's `since` advanced never backfills; opening it waits a full
relay round-trip.
2. **We can miss messages.** A fixed `limit=200` window has no way to reach older
history, and no demand-driven paging: scroll up past 200 and there is nothing
behind it. There is also a **serving-relay keying hazard** (see below) where a
referenced group message lands in a channel the UI never reads.
Every *other* chat surface in the app already solved this with a **two-subscription
model** — an always-on live tail + an on-demand backward history pager — and there
is a reusable framework for it. Group chat is the outlier that never adopted it.
## Goal
Split group chat into the same shape every other chat uses, and delete the
duplication:
- **State** (metadata / roster / roles / pins) — small replaceable events →
**one always-on account subscription**, gated on the NIP-29 settings toggle.
The cache is always current; no per-screen metadata re-fetch.
- **Content** (kind 9 chat + polls) — high volume → **live tail + backward history
pager**, exactly like NIP-04 DMs and Concord channels. Gap-proof (`RelayLoadingCursors`
handles cache-prune rewind), demand-driven by the visible feed, reconnect-safe.
No message path that delivers a group message today may be dropped.
## Reused framework (do not reimplement)
Mapped end-to-end from the NIP-04 DM stack and the **Concord channel** stack, which
is the closest existing template (a public group channel already paged this way):
| Piece | Location | Role |
|---|---|---|
| `BackwardRelayPager(name, pageLimit, liveTailSeconds)` | `commons/.../relayClient/paging/` | single-active per-relay backward orchestrator |
| `RelayLoadingCursors` | `quartz/.../relay/client/paging/` | per-relay `until`/`reached`/`done` cursors + `rewindTo` (prune realign) — **one instance per group scope** |
| `WindowLoadTracker` + `trackingListener` | `commons/.../relayClient/paging/` | live-tail "all relays settled" indicator |
| `PagingStatus`, `RelayPagingProgress` | paging pkg / quartz | atomic display snapshot |
| `RelayReachCursor` / `RelayReachSentinels` / `RelayReachMarkers` | `commons/.../ui/feeds/RelayReachMarker.kt` | viewport-driven "load older" markers |
| `DmHistoryLoadingCard`, `RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` | `amethyst/.../chats/feed/ChatFeedView.kt` | shared feed hooks |
| `DmHistoryTuning.recentBoundary()` | `commons/.../model/privateChats/` | shared live-tail floor (7 days) |
**Direct templates to copy:**
`ConcordChannelHistorySubAssembler` + `ConcordChannelHistoryFilterAssembler` +
`ConcordChannelHistorySubscription` + `ConcordChannelScreen`'s
`ConcordBackfillHistoryToWindow`; and `ChatroomNip04SubAssembler` (live tail) /
`ConcordChannelFilterAssembler` (batched always-on live).
## Target architecture
Four concerns, mirroring the DM stack (rooms-list tail + per-conversation tail +
per-conversation history) plus a groups-only always-on state sub.
1. **`RelayGroupStateSubAssembler`** — *always-on*, account-keyed.
Roster `#d` (39000/39001/39002/39003/39005) batched one filter per host relay
across the joined set. Keeps `since` (tiny replaceable events; reconnect just
re-confirms). Mounted at `LoggedInPage` (like `AccountFilterAssemblerSubscription`),
gated on `ChatFeedType.NIP29`. **This is today's `RelayGroupMyJoinedGroups` roster
path, promoted to always-on and stripped of content.**
2. **`RelayGroupPreviewTailSubAssembler`** — *always-on*, account-keyed, batched.
Content `#h` (kind 9 + poll) across **all** `liveRelayGroupList` group ids,
`since = recentBoundary()`, **no per-group limit** (a time floor bounds it, so it
batches into one filter per relay). `WindowLoadTracker`. Drives Messages-list
previews and keeps joined groups' recent chat live app-wide. **Replaces
`RelayGroupMyJoinedGroups` content path (A).** Batching + time-floor `since`
eliminates both the per-group-`since` bug and the reconnect re-download.
3. **`RelayGroupChatTailSubAssembler`** — per-open-`GroupId`, live tail for the
*currently open* group: content `#h` (9 + poll), `since = recentBoundary()`, host
relay. Covers recent + live updates for **any** open group, **including non-joined**
groups opened by link (which the batched preview tail — joined-only — doesn't cover).
Mirrors the DM per-conversation live tail.
4. **`RelayGroupChatHistorySubAssembler`** — per-open-`GroupId`, `BackwardRelayPager`
(`liveTailSeconds` = 7d floor; the tails cover above it), cursors on
`RelayGroupChannel.history`, content `#h` (9 + poll, **all authors**) `until`+`limit`
on the host relay. Demand-driven by the feed markers; eager `advanceAll()` backfill
to a window target on open. **Replaces ChannelPublic-open content (C) and
ChannelFromUser (D).** All-authors, so it re-materializes my own history too.
`ChannelFeedFilter` is unchanged — it reads `channel.notes`, so every path that fills
the cache surfaces. (It has **no `limit()`**, so it already renders whatever is cached.)
## Message-coverage proof (can't-miss-messages checklist)
Every current content-delivery path and what covers it after:
| Path (today) | Kinds / scope | After |
|---|---|---|
| **A** MyJoined content (50) | 9,poll `#h` joined | **Preview tail (batched `#h`, since=window)** for previews + **chat tail** when open |
| **B** Warmup content (50) | 9,poll,11,1111 `#h` card | **KEEP** — non-joined cards/discovery aren't in the joined tail (screen-dependent, per design) |
| **C** ChannelPublic-open content (200) | 9,poll `#h` open | **Chat tail (recent) + history pager (older, gap-proof)** |
| **D** ChannelFromUser (`authors=me`) | 9,poll `#h` me | **History pager (all-authors) + tail + optimistic-send attach + host echo** → redundant |
| **E** ThreadFeed | 11,1111 `#h` | **KEEP** (Threads screen; separate `threadNotes` feed). Pager adoption is a follow-up. |
| **F** Notifications | 7,9,1111,1068,… `#h`+`#p=me` | **KEEP** — always-on, p-tags-me; unchanged bonus |
| §3 by-id (`filterMissingEvents`) | ids | **KEEP** — quotes/replies/mentions; **+ serving-relay fix below** |
| §3 pinned by-id backfill | ids `filterMetadataToRelayGroup` | **KEEP** (host relay; older-than-window pins) |
| §3 replies/reactions `#e/#q` | 1111 etc. | **KEEP** — comments never attach to timeline (by design) |
**Serving-relay keying hazard (real, pre-existing — fix as part of "can't miss").**
`attachToRelayGroupIfScoped` keys the channel by `GroupId(groupId, servingRelay)`.
All subscriptions here are host-pinned, so they're safe. But `filterMissingEvents`
can deliver a referenced group message from a **non-host** relay, filing it under a
different channel object than the host-keyed one the UI reads → cached but invisible.
Fix: when attaching a group-scoped content event, if exactly one existing
`RelayGroupChannel` carries that `groupId` (the joined/host one), attach there
instead of minting a `(groupId, servingRelay)` channel — reusing the existing
`singleOrNull`-by-groupId resolution already used for the `relay == null` optimistic
branch. Ambiguous ids (the relay-wide `_` group joined on several relays) keep
serving-relay keying.
## File-by-file changes
**New (`commons` model):**
- `RelayGroupChannel`: add `val history = RelayLoadingCursors()` (mirror `ConcordChannel.history`).
**New (`amethyst` datasource, per templates):**
- `RelayGroupStateFilterAssembler` (+ SubAssembler) — always-on roster.
- `RelayGroupPreviewTailFilterAssembler` (+ SubAssembler) — batched preview tail.
- `RelayGroupChatTailFilterAssembler` (+ SubAssembler) — per-open live tail.
- `RelayGroupChatHistoryFilterAssembler` (+ SubAssembler) — per-open history pager.
- Subscription composables for each (`*Subscription`), copying the Concord ones.
**Modified:**
- `RelaySubscriptionsCoordinator`: register the four new assemblers; drop the retired ones (see below).
- `LoggedInPage`: mount `RelayGroupStateSubscription` + `RelayGroupPreviewTailSubscription` (always-on, gated).
- `RelayGroupChannelView`: mount chat-tail + history subscriptions; wire
`RefreshingChatroomFeedView(olderBoundary, markersInGap, sentinels)` + a
`BackfillHistoryToWindow` (copy `ConcordBackfillHistoryToWindow`).
- `MessagesSinglePane`/`MessagesTwoPane`: drop `RelayGroupMyJoinedGroupsSubscription`
(its roster role moves to the always-on state sub; previews come from the tail).
- `LocalCache.attachToRelayGroupIfScoped`: host-relay normalization (serving-relay fix).
- `AccountViewModel.dataSources()`: expose the four new assemblers; remove retired handles.
**Retired:**
- `RelayGroupMyJoinedGroupsFilterAssembler` **content path** → deleted; the file's
roster role becomes `RelayGroupStateFilterAssembler` (rename/replace).
- `ChannelPublicFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMessagesToRelayGroup`
+ `filterMetadataToRelayGroup`) → removed; metadata now always-on, content now tail+pager.
(Keep `filterMetadataToRelayGroup`'s **pinned-id backfill** — re-home it on the chat-tail or a
small pin sub so older-than-window pins still resolve.)
- `ChannelFromUserFilterSubAssembler` **`RelayGroupChannel` branch** (`filterMyMessagesToRelayGroup`) → removed.
- Keep `RelayGroupWarmup*` (non-joined cards), `RelayGroupsOnRelay*` (directory),
`RelayGroupsDiscovery*` (discover feed), `RelayGroupThreadFeed*` (threads), and the
notifications path unchanged.
## Rollout order (additive first, retire last — never a window where messages drop)
1. **Additive, no removals:** add `RelayGroupChannel.history`; add the four new
assemblers + subscriptions + coordinator/dataSources handles + `LoggedInPage`
and `RelayGroupChannelView` wiring. New content now flows through tail+pager
**alongside** the old A/C/D (harmless dedup by id). Compile + smoke.
2. **Serving-relay normalization** in `LocalCache` (independent correctness fix).
3. **Retire** A-content, C-relay-group-branch, D-relay-group-branch; move roster to
the always-on state sub; drop the Messages-pane `MyJoinedGroups` mount. Compile.
4. Re-home the pinned-id backfill; delete now-dead code; `spotlessApply`; full suite.
## Edge cases
- **Quiet group** (newest message older than the 7-day tail): won't appear in the
preview tail; its Messages row falls back to cached / placeholder (same as NIP-04).
Opening it → the history pager's eager backfill loads it. Optional: a one-shot
newest-1 per quiet joined group in the state sub's initial snapshot.
- **Non-joined open group:** covered by the per-open chat tail + history pager
(both per-`GroupId`, no joined-list dependency).
- **Reconnect:** tails carry `since=recentBoundary()` (time floor, shared-safe,
incremental); history is `until`-based (position, not reconnect-sensitive);
`FiltersChanged` already ignores `since`-only changes → no full replay.
- **Threads:** unchanged this pass; a follow-up can point `RelayGroupThreadFeed` at
a second `BackwardRelayPager` on `RelayGroupChannel` for kind 11/1111.
## Testing
- Unit: preview-tail filter batches one `#h` filter per relay with
`since=recentBoundary()` and no per-group limit; history filter emits only for
armed relays at their `requestedUntil`; state filter emits roster `#d` per relay.
- Cursor behavior is already covered by `RelayLoadingCursors` tests (reused).
- Manual (amy / device): join a group after session start → open → history backfills;
scroll up past the window → older pages load; reconnect → no full re-download;
quote a group message from a non-host relay → it appears in the group.

View File

@@ -0,0 +1,177 @@
# NIP-29 group-chat loading — test plan (per screen × per assembler)
**For:** an AI validating branch `claude/nip29-group-load-perf-wz4yca` before trusting the
state-vs-content refactor (see `2026-07-18-nip29-group-chat-subscriptions.md`).
**Question this answers:** *does the correct data load on every screen, and can we ever miss a message?*
**Implemented on this branch (all headless-runnable tiers):**
- **Tier B — filter shapes, every assembler.**
- Assemblers 16 + card-warmup joined-skip + reconnect stability (`needsToResendRequest`) + directory: `amethyst/src/test/.../relayGroup/datasource/RelayGroupFilterBuildersTest.kt` (tests the pure `RelayGroupFilterBuilders.kt` the assemblers now delegate to).
- #9 ChannelPublic relay-group branch (state+pins, no message window): `.../publicChannels/datasource/subassemblies/FilterRelayGroupStateTest.kt`.
- #10 group notifications (`#p`+`#h`): `.../service/relayClient/reqCommand/account/nip01Notifications/FilterGroupNotificationsToPubkeyTest.kt`.
- #8 discovery `#p` roster augmentation: `.../relayGroup/datasource/subassemblies/FilterRelayGroupsByAuthorsTest.kt`.
- **Tier C — serves-the-shape, against the in-process `geode` relay** (`quartz/src/jvmAndroidTest/.../nip29RelayGroups/RelayGroupFilterServingRelayTest.kt`): C1 state `#d`, C2 batched preview tail, C5 threads, C6 pinned-body-below-window by id, C7 notification `#p`+`#h`, C8 directory.
- **Tier C3 / E1 — can't-miss + resilience** (`quartz/src/jvmAndroidTest/.../paging/RelayGroupHistoryPagingRelayTest.kt`): backward `#h` walk covers every message once + stops on empty page; same-relay group isolation with overlapping `createdAt`; the **production `RelayLoadingCursors`** driven to the bottom over the wire; short-page-≠-exhaustion.
**Deliberately not duplicated (already covered generically at the unit level):** echo-newest→done and rewind/prune are in `RelayLoadingCursorsTest`; no-EOSE watchdog in `WindowLoadTrackerIdleTest`; auth-CLOSED/stall/cannot-connect in `BackwardRelayPagerTest`. E1's remaining hostile-relay faults (ignore-`since`, out-of-order, AUTH-CLOSE, silence) are those same state-machine paths — re-asserting them under NIP-29 naming adds no coverage since the cursor/pager never sees the `#h` filter, only `onEvent(createdAt)`/`onEose`.
**Not headless-runnable in this environment (flagged for a human):**
- **Tier D** (Android emulator/device, per screen) — needs a device; each row must be run and any unrun row flagged.
- **Tier E2** (conformance against a *real* third-party NIP-29 relay in a container) — non-deterministic + needs a container image; geode/strfry are generic and can't surface a real NIP-29 relay's bugs.
## What is / isn't verifiable headless
| Layer | Harness | Covers |
|---|---|---|
| Filter **shapes** each assembler builds | amethyst JVM unit tests (new) | the REQ is correct for the screen's job |
| Relay **serves** those filters; **ingest** into `LocalCache``RelayGroupChannel.notes`; paging framework | **`amy` + `geode`/`amy serve`** (drives the same quartz+commons client) | the reused machinery + filter shapes work against a real relay |
| **Screen loading** (mount → subscribe → feed renders); UI marker/sentinel→`advance`; always-on mounting; backfill-to-window loop | **Android emulator/device** | the amethyst wiring end-to-end |
The amethyst *assemblers* are Android-module, so `amy` cannot invoke them directly — it validates the
**framework + filter shapes + ingest** they depend on. The **screen** rows below therefore have a
headless part (unit + amy) and a device part; do both, and mark any device row you couldn't run.
## The assemblers under test (all of them)
| # | Assembler | Mounts on | Must load |
|---|---|---|---|
| 1 | `RelayGroupJoinedState` (always-on) | LoggedInPage → every screen | joined groups' 39000/1/2/3/5 → name, roster, roles, pins, my membership |
| 2 | `RelayGroupJoinedChatTail` (always-on) | LoggedInPage → Messages | joined groups' recent chat (`#h` since=window) → true newest-message previews |
| 3 | `RelayGroupOpenChatTail` | open group chat screen | the open group's recent chat + live (incl. **non-joined**) |
| 4 | `RelayGroupOpenChatHistory` | open group chat screen | older chat on demand (`#h` until+limit), gap-proof |
| 5 | `RelayGroupOpenThreads` | Threads tab | kind-11/1111 threads |
| 6 | `RelayGroupCardWarmup` | discovery cards, relay channel-list, members/metadata/parent screens | a **non-joined** card's metadata + preview; **skips joined** groups |
| 7 | `RelayGroupsOnRelay` | relay channel-list, subgroups bar, parent picker | a host relay's whole group directory |
| 8 | `RelayGroupsDiscovery` | Discovery screen | cross-relay discovery feed (by follows / global) |
| 9 | `ChannelPublicFilter` (relay-group branch) | open group chat screen | open group metadata + **pinned-id backfill** (incl. non-joined; pins older than window) |
| 10 | `filterGroupNotificationsToPubkey` (always-on notifications) | account-level | group content that **p-tags me**, even if I never opened the group |
## Harness setup (headless)
```bash
./gradlew :cli:installDist # build amy
RELAY=ws://127.0.0.1:7447
amy serve --port 7447 & # embedded relay (geode); or run :geode directly
# Identities: one "relay/operator" key (signs 39xxx), a few member keys, and "me".
# Seed a group G on the relay:
amy relaygroup create --relay $RELAY --gid G --name "Test" ... # 39000/39001/39002
# Seed chat spanning the live-tail boundary (7d): messages older AND newer than now-7d.
for t in <timestamps old→new>; do amy publish --relay $RELAY --kind 9 --tag h=G --created-at $t "msg $t"; done
# Seed: kind-11 thread + kind-1111 reply (h=G); a pinned kind-9 older than 7d + 39005 pin list;
# one kind-9 that p-tags "me"; a SECOND group G2 on the same relay (batching); a group on a
# second relay R2 (multi-relay); a group with <LIMIT total messages (small-group path).
```
Discover exact flags with `amy <verb> --help` (`fetch`/`subscribe`/`publish`/`relaygroup`).
`amy fetch --json` gives machine-checkable output for assertions.
---
## Tier A — baseline (must stay green)
```bash
./gradlew :amethyst:compilePlayDebugKotlin
./gradlew :quartz:jvmTest :commons:jvmTest :amethyst:testPlayDebugUnitTest :cli:test
./gradlew spotlessCheck
```
## Tier B — new amethyst unit tests (filter shapes per assembler)
Construct a minimal `Account` with `relayGroupList.liveRelayGroupList` = {G@R, G2@R} (+ a mock
`INostrClient`). If wiring a full `Account` is too heavy, **first refactor the filter construction out
of each `updateFilter` into a pure function** (`buildJoinedChatTailFilters(joinedTags, since)`,
`buildOpenChatHistoryFilters(groupId, armed, until, limit)`, …) and test those — this is itself a
worthwhile testability change. Assert, per assembler:
- **1 State:** one `#d` filter per host relay; kinds = 39000/39001/39002/39003/39005; `d` = all joined ids on that relay; `since` = shared per-relay EOSE. Disabled when NIP-29 toggle off / joined empty.
- **2 JoinedChatTail:** one `#h` filter per host relay; kinds = [9,poll]; `h` = all joined ids on that relay; `since = recentBoundary()`; **no per-group `limit`**. Two groups on one relay ⇒ **one** filter.
- **3 OpenChatTail:** one `#h` filter, host relay, kinds [9,poll], `since = recentBoundary()`, the single open group id.
- **4 OpenChatHistory:** with no relay armed ⇒ empty; after `advance(relay)` ⇒ one `#h` filter at `requestedUntilFor(relay)`, `limit = pageLimit`, **all authors** (no `authors`).
- **5 OpenThreads:** `#h`, kinds [11,1111], host relay.
- **6 CardWarmup:** a **joined** group ⇒ `emptyList()`; a **non-joined** group ⇒ metadata (unless contentOnly) + `#h` content (9,poll,11,1111) `limit`.
- **7 OnRelay:** directory `#`-less filter, kinds 39000-39003, `limit 500`, that relay.
- **8 Discovery:** by-follows + host-relay `#p` roster augmentation (see `RelayGroupsDiscoverySubAssembler`); global variant.
- **9 ChannelPublic relay-group branch:** returns **only** `filterRelayGroupState` (metadata + pin ids) — **no** message-window filter.
- **Reconnect stability:** re-run each `updateFilter` after a simulated EOSE; assert `FiltersChanged.needsToResendRequest(old,new)` is **false** for the tails/state (a `since`-only bump) — i.e. no full replay.
## Tier C — `amy` + relay integration (framework, ingest, can't-miss)
Issue the **exact filter shapes** from Tier B against the seeded relay and assert results:
- **C1 State load:** `amy fetch --kind 39000,39001,39002,39003,39005 --tag d=G --json` returns the seeded state. (screen-1)
- **C2 Preview/tail:** `amy fetch --kind 9 --tag h=G --since <now-7d> --json` returns only in-window messages; the newest equals the true newest. Batched: `--tag h=G --tag h=G2` returns both groups' recent in one query. (screens 2,3)
- **C3 History paging (CAN'T-MISS — the crown jewel):** seed **N=120** messages (older than 7d, spread over months). Starting `until=now`, repeatedly `amy fetch --kind 9 --tag h=G --until <cursor> --limit 50 --json`, setting the next `until = oldest.created_at - 1`, until an empty page. Assert the **union of all pages = all 120 ids, no gaps, no infinite loop** (mirror `RelayLoadingCursors.advance/onEose`). Then confirm a relay that returns the same newest events on a repeat page terminates (the `onEose` "not strictly older ⇒ done" guard). (screen-4)
- **C4 Ingest:** drive `amy subscribe`/`fetch` so events flow through the real client, then assert they land in a `RelayGroupChannel` keyed by `GroupId(G, R)` and surface via the `ChannelFeedFilter` predicate (kind 9/poll in, 1111 out). (screens 2,3)
- **C5 Threads:** `--kind 11,1111 --tag h=G` returns thread + reply; confirm 1111 attaches to threads, not the chat timeline. (screen-5)
- **C6 Pins:** a pinned kind-9 older than the window is **not** returned by C2 but **is** by `amy fetch --ids <pinnedId>` — proving the pinned-id backfill path still reaches it. (screen-9)
- **C7 Notifications:** `--kind 9 --tag h=G --tag p=<me>` returns the me-tagged message. (screen-10)
- **C8 Directory / discovery:** `--kind 39000 --limit 500` on R lists G+G2; a `#p=<follow>` roster query on the host relay surfaces a follow's group (the discovery augmentation). (screens 7,8)
- **C9 Multi-relay + reconnect:** repeat C2 against R and R2; drop and re-issue the subscription and confirm (via `--json` counts / relay logs) that a `since`-carrying re-REQ returns only the tail, not a full replay.
## Tier D — Android app, per screen (emulator/device; flag if unrunnable)
Boot `:amethyst:installDebug` against the seeded relay (point the account's relay list at `$RELAY`).
Watch logcat: `adb logcat | grep -E "DMPagination|relayGroup"`.
For **each screen**, the pass criteria:
- **D1 Messages list (1,2):** cold start with app already having joined G → the G row shows its **true newest** message (not a stale/scattered one), and its name/avatar (state). Join **G2 mid-session** (don't restart) → within seconds G2 appears with a real preview — *this is the original bug; it must now pass.*
- **D2 Open joined group (3,4,9 + backfill):** tap G → lands on a populated first screen (~50, the backfill-to-window), name/pins present. **Scroll up** past the window → older pages load, the reach marker advances, the "loading older" card shows then flips to "all caught up" at the bottom. No duplicate rows.
- **D3 Open non-joined group by link (3,4,9):** open a `naddr`/link to a group you have **not** joined → recent chat + live updates load (OpenChatTail) and scroll-up pages (OpenChatHistory), even though it's absent from the joined tail.
- **D4 Threads tab (5):** open Threads → kind-11 threads list; open one → its 1111 replies.
- **D5 Discovery (8,6):** open Discovery → groups list; a card fills name+activity (CardWarmup for non-joined). A **joined** group shown in "My Groups" still renders (from cache) though CardWarmup emits nothing for it.
- **D6 Relay channel-list / browse (7,6):** browse a relay → its directory lists groups; tapping one warms + opens.
- **D7 Members / Metadata screens (6/1):** roster + roles render.
- **D8 Reconnect (2,3,4):** toggle airplane mode on the open group and Messages → on reconnect, logcat shows incremental `since`/`until` REQs, **not** a full page replay; no missing or duplicated messages.
- **D9 Notifications (10):** with G *not* open, have another key post a message p-tagging me → it appears in notifications / unread.
- **D10 Quiet group:** a group whose newest message is older than 7d → Messages row falls back to cached/placeholder (documented limitation), and opening it backfills via the pager.
## Tier E — relay-behavior resilience & third-party conformance
Tiers C/D run against geode/`amy serve` — a **compliant relay we control**. Real NIP-29 groups live on
relays managed by other people, which have bugs and quirks. Two distinct concerns:
### E1 — client resilience to a MISBEHAVING relay (deterministic, mock)
Build a scriptable WebSocket relay (reuse the quartz relay-server + `RelayClientTestFakes`) that injects
one fault per run; assert the tail/pager still **converge** — all messages ingested, no infinite
`advance`, no hang, correct terminal state (`done` vs `stalled`), no duplicate rows:
- ignores `since` (returns everything) → tail must **dedup**, not duplicate.
- ignores / partial `until` → pager makes progress or marks done, **never loops**.
- **short page** (returns < `limit` though more exist) NOT exhaustion (only an *empty* page ends a relay).
- **echoes the same newest events every page** `RelayLoadingCursors.onEose` "not strictly older done" fires.
- out-of-order / duplicate events cursor takes `min(createdAt)`; dedup by id.
- EOSE before any event, or **no EOSE at all** `WindowLoadTracker` idle/abs-cap; pager silence watchdog `stalled`.
- **AUTH-required CLOSED("auth-required")** relay `stalled`-but-kept; re-`advance` retries; **not silently dropped**.
- mid-stream socket drop resubscribe with `since`/`until`, **no full replay, no gap** (`rewindTo` on prune).
- relay result cap below `limit` treated like a short page.
`UntilLimitPagingRelayTest` + `BackwardRelayPagerTest` already cover the empty-page / until-limit-walk /
echo-newest cases for the **generic** pager. E1 is to (a) re-run them against the NIP-29 `#h` filter
shapes and (b) add the not-yet-covered faults (ignore-since, out-of-order, AUTH-CLOSE, silence, reorder).
### E2 — conformance against REAL NIP-29 relay implementations (surfaces THEIR bugs)
geode / strfry / nostr-rs-relay are **generic** (store+serve by tag; no NIP-29 semantics), so they can't
surface a real NIP-29 relay's bugs. Point the harness (reuse relayBench's `RelayUnderTest` adapter) at an
actual NIP-29 relay (e.g. relay29, chorus, a self-hosted groups relay) in a **container**, seed a group
via `amy relaygroup`, and run C1C9 + E1's corpus. A failure is a **relay** bug or a client/relay
mismatch a NIP-29 conformance report the operator can act on. Cover the behaviors only a real NIP-29
relay has:
- **AUTH gating** on closed/private groups (39002 membership): does the client AUTH and then receive the `#h` timeline?
- **relay-signed 39xxx** (the relay's own key) the `isRelaySignedGroupEvent` gate.
- **`previous`-tag fork rejection** on send (a relay rejecting an event whose `previous` refs it doesn't recognise).
- the relay's actual **`since`/`until` inclusivity** and **result caps / rate limits** on the `#h` timeline.
Public relays are non-deterministic (live data): use a containerized instance for CI, a public one only
for exploratory runs. **E1 hardens our client against buggy relays; E2 tells us which third-party relay
is buggy** both are needed before trusting "loads correctly" in the wild.
## Cross-cutting invariants (assert throughout)
- **No missed messages:** the union of tail + history + pins + notifications = the full timeline; C3 is the decisive test. Also exercise `RelayLoadingCursors.rewindTo` trim the cache below the window, page again, confirm the pruned band re-loads.
- **No double-download on reconnect** (C9/D8).
- **Retirement left no gap:** with `RelayGroupMyJoinedGroups` deleted and `ChannelPublic`/`ChannelFromUser` relay-group content removed, D1/D2/D3 still load proving the tail+pager replaced them.
- **CardWarmup joined-skip** (Tier B #6 / D5): a joined card issues no warmup REQ.
- **Serving-relay hazard (FIXED):** a group message fetched from a **non-host** relay (e.g. `filterMissingEvents` quote resolution) used to be filed under `GroupId(G, otherR)` and lost. `LocalCache.attachToRelayGroupIfScoped`/`attachThreadToRelayGroupIfScoped` now redirect a stray (no channel for the serving relay) to the group's single confirmed **host** channel via `redirectStrayRelayGroupContent`, keyed off `RelayGroupChannel.hasRelaySignedState()` (a phantom never has relay-signed state, so the redirect only ever lands on a real host strictly safe, the common host-pinned path is an untouched O(1) fast path). Covered by `RelayGroupContentRoutingTest`. **Device-untested:** the pure router + channel signal are unit-tested; the LocalCache wiring is a guarded fast-path/slow-path swap that still needs a device pass (Tier D) to confirm end-to-end.
## Exit criteria
- Tier A green; Tier B all assertions pass; Tier C1C9 pass (esp. **C3**).
- Tier D1D9 pass on device, or each unrun row is explicitly flagged for a human.
- Known-failing by design until follow-ups: the serving-relay hazard row and (if unadopted) a threads pager.

View File

@@ -0,0 +1,190 @@
# v1.13.0 release QA — coverage, open findings, and recipes
One extended testing session against v1.13.0 (~2011 commits since v1.12.6). Fixes landed on
`fix/napplet-account-isolation-and-consent` (30 commits); each commit message carries its own
root-cause reasoning and is the better reference for *why* a given change looks the way it does.
This document records what that session **could not** capture in commit messages: what was actually
exercised, what was not, what we chose to leave broken, and how to reproduce the setups.
**Device under test:** Samsung SM-T220 tablet, Android 14, `sw600dp`, `play`/`benchmark`, arm64.
Everything below is that one configuration unless stated.
---
## 1. Coverage
### Exercised on device
| Area | Notes |
|---|---|
| Upgrade path | install over a month-old build; migrations survived, ~880 ms cold start |
| Napplet / web app per-account isolation | leak found and fixed; each account now has its own jar |
| Embedded tab rebuild on account switch | blank-tab bug found and fixed; verified both directions + a forced-failure A/B |
| Launch-account signing binding | desync reproduced end to end, then verified fixed |
| NIP-46 remote signer | 13 checks, all passing (pairing gate, 22242 prompt, decrypt counterparty/plaintext/narrow grant/scoping) |
| Concord | invite consent gate, private/voice rename, role rank gate, revoke gate |
| NIP-29 relay groups | directory browse (crash found), naddr deep-link join, membership resolution |
| Breadth sweep | Messages, NIP-29, Git, Podcasts, Blossom list, Location, Theming |
| Location | map picker + teleport, before/after on 4 symptoms, composer path |
| Notifications | tab showed ~3 items; root-caused to a `since` deadlock and fixed — feed now scrolls back 16 months |
| Concord role grants | picker built and device-verified (rank gating, preselection, survives the fold) |
### Fixed but **only unit-verified** — never run on a device
Concord rollback floor · stranded recovery · member-set gap · invite expiry · moderation head ·
**chain-poisoning fix** · community-list unknown-key preservation · V4V fee clamp · Cashu SSRF
validator · Blossom 402 (cap / re-prompt / double-spend / `X-Reason`) · amountless-invoice display ·
**napplet consent diff dialog** (never seen rendered) · connect-dialog capability disclosure ·
`identity.watch` DENY · DM ciphertext previews and the `User` metadata race · chat date separators ·
podcast duplicate description · Concord leave affordance · the three revocation wirings.
### Never opened at all
Nests / audio rooms (`quic` + MoQ) · Marmot / MLS · **the entire Desktop app** (where Privacy Lock
actually ships) · Blossom "Sync all" (skipped deliberately — uploads to real servers) · podcast
chapters / transcripts / credits · Git branch switching · Messages live-typing and per-type toggles ·
real payments (zaps, V4V streaming, Cashu redeem) · push notifications · search · Calendar, Chess,
Polls, Marketplace, Workouts, Badges, Follow Packs, Emojis, HLS Upload, App Store, Live Streams.
NIP-29 admin: the menu is reachable and renders, but Edit metadata, invite creation, subgroups and
pinned messages were never exercised.
### Platform gaps
- **Android 14 only.** `targetSdk` is 37; Android 15+ forces edge-to-edge and that path is untested.
An emulator makes this cheap and it is the highest-value remaining gap.
- **Tablet only** — no phone layout. **`play` only** — no fdroid. **`benchmark` only** — the real
`release` (full R8) has never been built or run. **arm64 only.**
- **Amber / NIP-55** external signer never tested, including a known decrypt double-prompt risk.
- **Tor-on paths** — Tor was disabled for untrusted relays mid-session and not restored.
---
## 2. Open findings (known, deliberately not fixed)
**Release mechanics**
- `appCode` still `454` and `app` still `1.12.6` — Play hard-rejects a duplicate versionCode.
- Firebase `TransportRuntime` cannot schedule (`JobInfoSchedulerService` missing from the merged
manifest). If this reproduces in `release`, **Crashlytics delivery is broken** and the release
ships blind.
**Correctness / UX**
- Tor settings do not take effect until app restart, with no indication.
- An unreachable relay is reported as "No groups on this relay yet" — indistinguishable from empty.
- NIP-29: a stale "Requested" join state is never reconciled against an arriving 39002 roster.
- Concord: leaving does not unpin from the bottom bar, leaving a dead tab.
- Read-only accounts render nothing for a kind:4 chatroom body (better than ciphertext, still wrong).
- Modal geohash picker header is overdrawn by the MapView (pre-existing).
- `amy relaygroup create` reports success on relays that silently reject it — always verify with `info`.
- `amy login bunker://…` hangs and never delivers a `connect`.
**Security / protocol**
- NIP-46 "Generate a new address" claims to disconnect every app; it rotates the transport key and
revokes nothing.
- WebView storage profiles are never deleted on logout — a removed account's cookies persist.
Requires a broker message so `:napplet` can call `ProfileStore.deleteProfile`.
- Control-plane *edit* paths (`editConcordMetadata`, `grant`, channel edits) still drop unknown JSON
keys; only the community list was fixed.
- **CORD-05: `community_id` does not commit to `community_root`**, so a crafted invite can carry a
real community's identity with an attacker's root. **Armada has the identical gap** — this needs a
spec conversation, not a unilateral fix.
- **CORD-04: the BANLIST is not rank-gated, so any BAN holder can ban anyone — including the owner.**
Role/grant editions are rank-gated (`canActOn`), but a banlist edition is a single *whole-list*
entity, so no client rank-checks its contents; the gate is the author's BAN bit alone. A rank-5
moderator's ban of a rank-1 admin is therefore **accepted** by the fold, and the admin then loses
every permission (`hasPermission` is `!isBanned && …`). **Armada has the identical gap** — its
`banlistGate` calls the rank-blind `isAuthorized(.., Permissions.BAN)` while its role path uses
the rank-aware `canActOnPosition`.
**This is a conformance bug, NOT a spec gap** — an earlier note here said the opposite and was
wrong. CORD-04 §3 is explicit and normative: "One hard rule binds every action: the actor must
hold the required bit **and** *strictly* outrank its target — equal cannot act on equal (an admin
cannot ban a peer admin)", restated as step 3 of §5. Only §4, the section that defines the
Banlist, omits the rank half — and both independent implementations read §4 in isolation and made
the same mistake. Spec: <https://github.com/concord-protocol/concord> (`04.md`).
**FIXED and shipping**`AuthorityResolver` now enforces §3 as a *delta rule* (an edition may only
add/remove npubs its signer strictly outranks; the owner is never a valid target; unpermitted
entries are ignored rather than rejecting the edition, so a bulk-ban survives). The UI and the
ban/unban write path route through it too — `ConcordModeration.currentBanned` now reads the
*honored* banlist via the resolver instead of decoding the raw head, which also closes a
laundering path where our own next ban would re-publish an unauthorized entry under our signature.
**Known consequence: Armada has not shipped this, so banlists can differ between clients**
we ignore a ban Armada honors when the signer did not outrank the target. Deliberate.
Write-up to send upstream: `docs/concord-banlist-rank-conformance.md`.
Still open, both covered in the write-up: a banned member holding BAN can lift their own ban (the
gate reads role-derived permissions, so bans do not stick against any BAN holder — this one is a
genuine fixpoint-ordering question and needs a spec ruling), and a forked ban survives an unban
that does not chain onto it.
- Notification cards whose target note isn't in `LocalCache` render "Event is loading or can't be
found in your relay list" (seen on old zaps). `tagsAnEventByUser` needs the reacted-to note
loaded, so deep history stays partially unresolved. Cosmetic, pre-existing.
---
## 3. Setup recipes
**`amy` with an isolated identity** (never touch the maintainer's real one):
```
AMY=$(pwd)/cli/build/install/amy/bin/amy
H=/tmp/qa-home; mkdir -p $H
HOME=$H $AMY --account qa login <nsec> --secret-backend plaintext
```
`amy` scopes by `$HOME`, not a flag. `init` prompts for a passphrase and hangs without a TTY — use
`--secret-backend plaintext` for throwaway identities.
**NIP-29 test group.** `relaygroup create` on `communities.nos.social` and `relay.groups.nip29.com`
returned success but published nothing; `groups.0xchat.com` worked. Always confirm with
`relaygroup info`. To reach a group in a 1000+ entry directory, skip the UI and deep-link:
`adb shell am start -a android.intent.action.VIEW -d "nostr:<naddr>"`, encoded via
`amy encode naddr --pubkey <relay-nip11-pubkey> --kind 39000 --identifier <groupId> --relay <url>`.
To make a device account an admin, have it join first, read its pubkey from `relaygroup info`, then
`put-user … --role admin`.
**Proving "no network before consent."** Run a local relay (`amy serve`), expose it with
`adb reverse tcp:7777`, and make it the *only* relay in the artefact under test. Count events before
and after the user action — that turns "I didn't see traffic" into an actual measurement.
---
## 4. Patterns worth acting on
These recurred often enough to be process problems rather than individual bugs.
**Tests that assert the bug.** At least five encoded the buggy behaviour as intended — a NIP-46 test
named `getPublicKeyReturnsUserPubKeyWithoutAuthorization`, a V4V invariant only ever run on
well-formed input, a napplet session test that never crossed accounts. *Always verify a new
regression test fails without the fix* — and beware that **Gradle will serve a stale up-to-date
`jvmTest` and report BUILD SUCCESSFUL**, which makes that check silently lie. Use `--rerun-tasks`.
**Implemented-but-unreachable capabilities.** Five found: `leaveConcordCommunity`,
`ConcordInviteBundle.isExpired`, `NappletPermissionLedger.endSession`, `grantConcordRole`, and
`NappletBroker.revokeSessionGrants`. Each made a feature look complete to anyone reading the model
while being unreachable to users, and the first one actually invoked turned out to be **broken as
written**. A lint for "public capability with no caller outside its declaring file" would catch the
whole class cheaply.
**A narrow query window can deadlock against its own paging.** The Notifications tab asked relays
for 7 days, and its backward-paging fallback only armed once the feed held a *full page* — so a
quiet inbox could never fill a page, and therefore never widened the window. The EOSE `since` map
is in-memory, so every cold start re-pinned it. Look for this shape wherever a "load more" boundary
is gated on a full page: the empty state is self-sustaining. Note also that the relay-side `limit`
already bounds these queries, which is what makes dropping the time floor safe.
**Hypotheses need measurement, not plausibility.** Four confident diagnoses were wrong: the "npub in
title" bug was a `User` lazy-init data race, not a display bug; chat date separators were a
`reverseLayout` misconception, not bubble grouping; the map picker had no tile problem at all; and
NIP-29 membership was a *relay rejecting the REQ* (`blocked: it's not allowed to mix metadata kinds
with others`), not membership modelling. Instrument first.
**Check the reference implementation.** Reading Armada changed the answer three times out of three —
it corrected an owner-rotation rule that would have stranded owners, stopped an invite-binding "fix"
that was both interop-breaking and ineffective, and supplied the chain-poisoning design (gate *after*
folding, not before). Armada is **AGPLv3** and Amethyst is MIT: read for semantics, copy nothing.
**Comments encoding constraints are load-bearing.** The synchronous SharedPreferences read looks like
an obvious StrictMode fix; its comment records that an async hydrate reopens a settings-clobber race.
Removing it would have been a confident, review-passing regression.
**Beware concurrent agents and `git add -A`.** Two commits were contaminated, and one silently
committed another worker's temporary revert. Stage explicit paths, always.

View File

@@ -54,6 +54,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
<!-- Phone calls -->
@@ -151,6 +152,14 @@
<data android:scheme="amethyst+walletconnect" />
</intent-filter>
<!-- NIP-46: an app's `nostrconnect://` offer opens the signer screen, which pairs it. -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -365,6 +374,15 @@
android:stopWithTask="false"
android:exported="false" />
<!-- Keeps the "sync all blobs to all servers" (BUD-04) sweep alive while backgrounded.
dataSync is the correct type for an upload/download/sync; it must be started from the
foreground, which "Sync all" (user-initiated in the manager) satisfies. -->
<service
android:name=".service.uploads.blossom.BlossomSyncForegroundService"
android:foregroundServiceType="dataSync"
android:stopWithTask="false"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
@@ -463,14 +481,14 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".napplet.NappletConnectActivity"
android:name=".connectedApps.consent.SignerConnectActivity"
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:name=".connectedApps.consent.SignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"

View File

@@ -32,6 +32,9 @@ 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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -95,6 +98,10 @@ class Amethyst : Application() {
// Index device-local captured favicons (main process only; decorates favorites + suggestions).
BrowserIconRegistry.init(this)
// Warm the global-settings prefs off-main so the first (deliberately synchronous) read of
// them does not hit disk on the main thread. See LocalPreferences.warmGlobalSettings.
CoroutineScope(Dispatchers.IO).launch { LocalPreferences.warmGlobalSettings() }
// 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)

View File

@@ -27,6 +27,7 @@ import androidx.security.crypto.EncryptedSharedPreferences
import coil3.disk.DiskCache
import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
@@ -35,6 +36,8 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
@@ -51,7 +54,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
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.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
@@ -83,6 +85,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.diagnostics.BootRelayDiagnostics
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
@@ -106,6 +109,8 @@ import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.service.safeCacheDir
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorkGate
import com.vitorpamplona.amethyst.service.scheduledposts.ScheduledPostWorker
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncForegroundService
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.BlossomServerResolver
import com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe
import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory
@@ -690,10 +695,37 @@ class AppModules(
// Per-relay NIP-42 ALLOW/DENY overrides are now per-account (Account.relayAuthPermissions,
// backed by a file under accounts/<pubkey>/), so there is no app-wide store here anymore.
/**
* The account every napplet/web-app grant and byte of storage is scoped to. Read lazily on each
* call (never captured) so an account switch immediately moves embedded apps to the new account's
* namespace: an app authorized by one npub is never authorized under another.
*/
val nappletAccountScope: () -> String = { sessionManager.loggedInAccount()?.pubKey ?: "" }
// Singleton stores for napplet permissions — DataStore v1 enforces one instance per file.
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext, nappletAccountScope) }
/**
* The one napplet permission ledger for the main process. Its persistent half is just the store
* above, but it also holds the in-memory ALLOW_SESSION grants — and *those* only work if every
* caller shares this instance. The broker service and the Connected Apps screens used to build
* a ledger each, so a "Forget"/revoke tapped in the UI cleared the screen's own (always empty)
* session map while the grants the broker was actually consulting lived on untouched.
*
* Session lifetime is bounded by [com.vitorpamplona.amethyst.napplet.NappletBrokerService]'s
* onDestroy (all applet/browser surfaces gone), which calls `endSession()`.
*/
val nappletPermissionLedger by lazy { NappletPermissionLedger(nappletPermissionStore, nappletAccountScope) }
// NOT account-scoped here on purpose: this store is shared with NIP-46, whose coordinates already
// carry their owning account (`nip46:<signer>:<client>`) and whose sessions run for a specific
// account rather than the active one. The napplet path namespaces its own coordinate the same way
// (see NappletBroker.signerCoordinateFor) instead.
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -749,6 +781,10 @@ class AppModules(
// Focused timeline for the DM / gift-wrap loading path (tag: DMPagination).
// val dmDiagnostics = if (isDebug) DmRelayDiagnosticsLogger(client) else null
// Per-relay cold-start census: connection outcome by cause, REQ/EOSE/CLOSED accounting,
// and which relays actually carried the boot (tag: BootRelayDiag).
val bootDiagnostics = if (isDebug) BootRelayDiagnostics(client) else null
// Coordinates all subscriptions for the Nostr Client
val sources: RelaySubscriptionsCoordinator =
RelaySubscriptionsCoordinator(
@@ -791,6 +827,14 @@ class AppModules(
}
}
/** App-level BUD-04 mirror sweep, so "sync all" keeps running as the user navigates. */
val blossomMirrorQueue by lazy {
BlossomMirrorQueue(
scope = applicationIOScope,
onActive = { BlossomSyncForegroundService.start(appContext) },
)
}
val powJobRestorer by lazy {
PowJobRestorer(powPublishQueue, powJobStore, scheduledPostStore)
}
@@ -810,6 +854,8 @@ class AppModules(
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
)
val sessionManager =

View File

@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.Immutable
import androidx.core.content.edit
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
@@ -37,8 +38,10 @@ import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
@@ -54,6 +57,7 @@ import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent
import com.vitorpamplona.quartz.nip51Lists.favoriteAlgoFeedsList.FavoriteAlgoFeedsListEvent
import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent
import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
@@ -108,7 +112,13 @@ private object PrefKeys {
const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload"
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val MIRROR_UPLOADS_TO_ALL_SERVERS = "mirrorUploadsToAllServers"
const val OPTIMIZE_MEDIA_ON_UPLOAD = "optimizeMediaOnUpload"
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
const val NIP46_TRANSPORT_KEY = "nip46TransportKey"
const val NIP46_SEEN_IDS = "nip46SeenRequestIds"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -161,7 +171,10 @@ private object PrefKeys {
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_CONCORD_LIST = "latestConcordList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
const val CALLS_ENABLED = "calls_enabled"
const val HIDE_DELETE_REQUEST_DIALOG = "hide_delete_request_dialog"
const val HIDE_BLOCK_ALERT_DIALOG = "hide_block_alert_dialog"
@@ -170,6 +183,10 @@ private object PrefKeys {
const val DEFAULT_RELAY_AUTH_POLICY = "default_relay_auth_policy"
const val RELAY_GROUP_VIEW_MODE = "relay_group_view_mode"
const val CONCORD_VIEW_MODE = "concord_view_mode"
// Stores the DISABLED chat feed types (comma-joined codes) so absence = all-on and any newly
// added type defaults enabled for accounts that customized before it existed.
const val DISABLED_CHAT_FEEDS = "disabled_chat_feeds"
const val RELAY_AUTH_TRUST_MY_RELAYS = "relay_auth_trust_my_relays_and_venues"
const val RELAY_AUTH_TRUST_READ_FOLLOWS = "relay_auth_trust_read_follows"
const val RELAY_AUTH_TRUST_MESSAGE_FOLLOWS = "relay_auth_trust_message_follows"
@@ -225,6 +242,19 @@ object LocalPreferences {
// the source of truth, so there is no async hydrate that could clobber a user toggle.
private fun globalSettingsPrefs(): SharedPreferences = Amethyst.instance.appContext.getSharedPreferences("amethyst_global_settings", Context.MODE_PRIVATE)
/**
* Loads the global-settings prefs file into SharedPreferences' in-memory cache, off the main
* thread, so the first synchronous read below hits memory rather than disk.
*
* The read itself is deliberately synchronous — see [setNotificationServiceEnabled]: an async
* hydrate reintroduces a window where a late disk read clobbers a user's toggle. So this warms
* the cache instead of deferring the read. Best-effort: if a main-thread reader wins the race it
* simply pays the disk hit once, exactly as before.
*/
fun warmGlobalSettings() {
globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true)
}
private val notificationServiceEnabled: MutableStateFlow<Boolean> by lazy {
MutableStateFlow(globalSettingsPrefs().getBoolean(PrefKeys.NOTIFICATION_SERVICE_ENABLED, true))
}
@@ -464,7 +494,13 @@ object LocalPreferences {
putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload)
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, settings.mirrorUploadsToAllServers.value)
putBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, settings.optimizeMediaOnUpload.value)
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value)
putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -547,7 +583,10 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
putOrRemove(PrefKeys.LATEST_PAYMENT_TARGETS, settings.backupNipA3PaymentTargets)
putOrRemove(PrefKeys.LATEST_CASHU_WALLET, settings.backupCashuWallet)
putOrRemove(PrefKeys.LATEST_NUTZAP_INFO, settings.backupNutzapInfo)
@@ -560,6 +599,7 @@ object LocalPreferences {
putString(PrefKeys.DEFAULT_RELAY_AUTH_POLICY, settings.defaultRelayAuthPolicy.value.name)
putString(PrefKeys.RELAY_GROUP_VIEW_MODE, settings.relayGroupViewMode.value.name)
putString(PrefKeys.CONCORD_VIEW_MODE, settings.concordViewMode.value.name)
putString(PrefKeys.DISABLED_CHAT_FEEDS, ChatFeedType.encode(ChatFeedType.ALL - settings.enabledChatFeeds.value))
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, settings.relayAuthTrustMyRelaysAndVenues.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, settings.relayAuthTrustReadFollows.value)
putBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, settings.relayAuthTrustMessageFollows.value)
@@ -674,7 +714,13 @@ object LocalPreferences {
val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true)
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val mirrorUploadsToAllServers = getBoolean(PrefKeys.MIRROR_UPLOADS_TO_ALL_SERVERS, true)
val optimizeMediaOnUpload = getBoolean(PrefKeys.OPTIMIZE_MEDIA_ON_UPLOAD, false)
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: ""
val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf()
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
@@ -686,6 +732,7 @@ object LocalPreferences {
?: RelayAuthPolicy.CUSTOM
val relayGroupViewMode = RelayGroupViewMode.fromName(getString(PrefKeys.RELAY_GROUP_VIEW_MODE, null))
val concordViewMode = ConcordViewMode.fromName(getString(PrefKeys.CONCORD_VIEW_MODE, null))
val enabledChatFeeds = ChatFeedType.ALL - ChatFeedType.decode(getString(PrefKeys.DISABLED_CHAT_FEEDS, null))
val relayAuthTrustMyRelays = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MY_RELAYS, true)
val relayAuthTrustReadFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_READ_FOLLOWS, true)
val relayAuthTrustMessageFollows = getBoolean(PrefKeys.RELAY_AUTH_TRUST_MESSAGE_FOLLOWS, true)
@@ -725,7 +772,10 @@ object LocalPreferences {
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
val latestPaymentTargetsStr = getString(PrefKeys.LATEST_PAYMENT_TARGETS, null)
val latestCashuWalletStr = getString(PrefKeys.LATEST_CASHU_WALLET, null)
val latestNutzapInfoStr = getString(PrefKeys.LATEST_NUTZAP_INFO, null)
@@ -785,7 +835,10 @@ object LocalPreferences {
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
val latestPaymentTargets = async { parseEventOrNull<PaymentTargetsEvent>(latestPaymentTargetsStr) }
val latestCashuWallet =
async {
@@ -837,7 +890,10 @@ object LocalPreferences {
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestConcordListResolved = latestConcordList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
val latestPaymentTargetsResolved = latestPaymentTargets.await()
val latestCashuWalletResolved = latestCashuWallet.await()
val latestNutzapInfoResolved = latestNutzapInfo.await()
@@ -853,7 +909,13 @@ object LocalPreferences {
stripLocationOnUpload = stripLocationOnUpload,
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
mirrorUploadsToAllServers = MutableStateFlow(mirrorUploadsToAllServers),
optimizeMediaOnUpload = MutableStateFlow(optimizeMediaOnUpload),
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
nip46TransportKey = MutableStateFlow(nip46TransportKey),
nip46SeenRequestIds = MutableStateFlow(nip46SeenRequestIds),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
@@ -900,6 +962,7 @@ object LocalPreferences {
defaultRelayAuthPolicy = MutableStateFlow(defaultRelayAuthPolicy),
relayGroupViewMode = MutableStateFlow(relayGroupViewMode),
concordViewMode = MutableStateFlow(concordViewMode),
enabledChatFeeds = MutableStateFlow(enabledChatFeeds),
relayAuthTrustMyRelaysAndVenues = MutableStateFlow(relayAuthTrustMyRelays),
relayAuthTrustReadFollows = MutableStateFlow(relayAuthTrustReadFollows),
relayAuthTrustMessageFollows = MutableStateFlow(relayAuthTrustMessageFollows),
@@ -924,7 +987,10 @@ object LocalPreferences {
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,
lastReadPerRoute = MutableStateFlow(lastReadPerRouteResolved),
hasDonatedInVersion = MutableStateFlow(hasDonatedInVersion),
dismissedPollNoteIds = MutableStateFlow(dismissedPollNoteIds),

View File

@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.connectedApps
import android.content.Context
import androidx.datastore.core.DataStore
@@ -26,12 +26,14 @@ 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.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
@@ -102,21 +104,24 @@ class DataStoreNostrSignerPermissionStore(
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
override suspend fun allPolicies(): Map<String, AppSignerPolicy> =
// Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the
// caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher).
withContext(Dispatchers.IO) {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return@withContext 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
}
result
}
return result
}
override suspend fun allOpDecisions(coordinate: String): Map<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()

View File

@@ -0,0 +1,68 @@
/*
* 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.connectedApps.consent
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
/**
* The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in
* identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a
* raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture].
*/
@Composable
fun ConnectedAccountRow(
name: String,
picture: String?,
pubKey: String?,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
RobohashFallbackAsyncImage(
robot = pubKey ?: name,
model = picture,
contentDescription = null,
modifier = Modifier.size(26.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Text(
name,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}

View File

@@ -18,7 +18,7 @@
* 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
package com.vitorpamplona.amethyst.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
@@ -58,23 +58,24 @@ 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.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
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.MaterialSymbol
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() {
class SignerConnectActivity : 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)
val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN)
this.token = token
val info = token?.let { NappletConnectCoordinator.infoFor(it) }
val info = token?.let { SignerConnectCoordinator.infoFor(it) }
if (token == null || info == null) {
finish()
return
@@ -82,21 +83,21 @@ class NappletConnectActivity : ComponentActivity() {
setContent {
AmethystTheme {
NappletConnectScreen(
SignerConnectScreen(
info = info,
onConnect = { policy ->
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy))
finish()
},
onBlock = {
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Blocked)
SignerConnectCoordinator.complete(token, AppConnectResult.Blocked)
finish()
},
onCancel = {
decided = true
NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled)
SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled)
finish()
},
)
@@ -105,14 +106,14 @@ class NappletConnectActivity : ComponentActivity() {
}
override fun finish() {
if (!decided) token?.let { NappletConnectCoordinator.cancel(it) }
if (!decided) token?.let { SignerConnectCoordinator.cancel(it) }
super.finish()
}
}
@Composable
private fun NappletConnectScreen(
info: NappletConnectInfo,
private fun SignerConnectScreen(
info: SignerConnectInfo,
onConnect: (AppSignerPolicy) -> Unit,
onBlock: () -> Unit,
onCancel: () -> Unit,
@@ -168,12 +169,46 @@ private fun NappletConnectScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
// Show WHICH account is being connected (avatar + name), not a raw pubkey.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
} else {
Text(
info.domain,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
// What the app declared it needs (nostrconnect `perms`) — so consent is informed,
// not a silent grant. Approving connects and pre-grants exactly these.
if (info.requestedPermissions.isNotEmpty()) {
Spacer(Modifier.height(16.dp))
Surface(
modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(
stringResource(R.string.nip46_connect_requests_title),
style = MaterialTheme.typography.labelLarge,
)
info.requestedPermissions.forEach { perm ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Icon(
MaterialSymbols.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
Text(perm, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
Spacer(Modifier.height(16.dp))
@@ -194,21 +229,21 @@ private fun NappletConnectScreen(
) {
PolicyOption(
selected = selected == AppSignerPolicy.FULL_TRUST,
icon = "",
symbol = MaterialSymbols.LockOpen,
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 = "👍",
symbol = MaterialSymbols.Shield,
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 = "🕶",
symbol = MaterialSymbols.Lock,
label = stringResource(R.string.napplet_policy_paranoid),
description = stringResource(R.string.napplet_policy_paranoid_desc),
onClick = { selected = AppSignerPolicy.PARANOID },
@@ -249,7 +284,7 @@ private fun NappletConnectScreen(
@Composable
private fun PolicyOption(
selected: Boolean,
icon: String,
symbol: MaterialSymbol,
label: String,
description: String,
onClick: () -> Unit,
@@ -271,7 +306,12 @@ private fun PolicyOption(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(icon, style = MaterialTheme.typography.headlineSmall)
Icon(
symbol = symbol,
contentDescription = null,
tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(26.dp),
)
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)

View File

@@ -18,21 +18,36 @@
* 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
package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.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(
data class SignerConnectInfo(
val appletTitle: String,
val coordinate: String,
val domain: String,
val iconUrl: String? = null,
/**
* The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When
* [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back
* to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer),
* shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that
* carry no declaration (bunker connect), which just show the trust picker.
*/
val requestedPermissions: List<String> = emptyList(),
)
/**
@@ -40,9 +55,9 @@ data class NappletConnectInfo(
* the Activity resolves the deferred with the user's choice.
* A dismissed dialog resolves to [AppConnectResult.Cancelled] fails closed, no silent grant.
*/
object NappletConnectCoordinator {
object SignerConnectCoordinator {
private class Pending(
val info: NappletConnectInfo,
val info: SignerConnectInfo,
val deferred: CompletableDeferred<AppConnectResult>,
)
@@ -50,26 +65,41 @@ object NappletConnectCoordinator {
suspend fun requestConnect(
context: Context,
info: NappletConnectInfo,
info: SignerConnectInfo,
): 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),
)
// Fast path when Amethyst already owns the foreground; the full-screen-intent notification
// below is what surfaces the dialog when a connect request arrives while backgrounded (see
// SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs.
runCatching {
context.startActivity(
Intent(context, SignerConnectActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.putExtra(EXTRA_TOKEN, token),
)
}
val notificationId =
SignerConsentNotifier.show(
context = context,
activityClass = SignerConnectActivity::class.java,
extraKey = EXTRA_TOKEN,
token = token,
titleRes = R.string.nip46_signer_notif_connect_title,
)
return try {
deferred.await()
} finally {
pending.remove(token)
SignerConsentNotifier.cancel(context, notificationId)
}
}
fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info
fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info
fun complete(
token: String,

View File

@@ -0,0 +1,591 @@
/*
* 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.connectedApps.consent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
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.shape.CircleShape
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.Checkbox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
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.model.LocalCache
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.TimeUtils
class SignerConsentActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AmethystTheme {
// The signer services requests concurrently, so more than one may await consent. We never
// mix accounts in one sheet: render only the requests for the OLDEST-pending account as a
// group. When that account's group clears, the next account's requests render (a fresh
// per-account sheet). One request → the rich dialog; several → a batched list. When the
// whole queue empties, close.
val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle()
val group =
run {
val account = pending.firstOrNull()?.info?.accountPubKey
pending.filter { it.info.accountPubKey == account }
}
LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() }
when {
group.isEmpty() -> Unit
group.size == 1 -> {
val p = group.first()
SignerConsentDialog(
info = p.info,
onGrant = { SignerConsentCoordinator.complete(p.token, it) },
onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) },
)
}
else ->
BatchedConsentDialog(
pending = group,
onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) },
// Dismissing denies only THIS account's group; other accounts' requests stay
// pending and render next as their own sheet.
onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) },
)
}
}
}
}
// Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside)
// denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as
// this Activity finishes is owned by a freshly-launched instance, and denying it here would race
// that instance and reject a legitimate request. A process kill falls back to the bridge's 120s
// timeout, which also fails closed.
}
@Composable
private fun SignerConsentDialog(
info: SignerConsentInfo,
onGrant: (SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
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,
)
// Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex.
if (info.accountName != null) {
ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey)
}
// For a decrypt request, WHOSE conversation is being read is the decision. Show
// that person as an avatar + name, never as nothing.
if (info.counterpartyName != null) {
Text(
stringResource(R.string.nip46_signer_messages_with),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
ConnectedAccountRow(info.counterpartyName, info.counterpartyPicture, info.counterpartyPubKey)
}
}
Spacer(Modifier.height(12.dp))
Box(modifier = Modifier.padding(horizontal = 24.dp)) {
SignerConsentPreview(info)
}
Spacer(Modifier.height(16.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
// Primary: the NARROWEST "remember" available. For decrypt that is "always allow for
// Alice" — one broad decrypt grant would otherwise hand over every conversation
// forever, and scoping the op itself would mean a prompt per conversation.
val narrowOp = info.narrowOp
if (narrowOp != null && info.narrowOpLabel != null) {
Button(
onClick = { onGrant(SignerOpGrant.AllowForOp(narrowOp)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(info.narrowOpLabel)
}
// The broad grant stays available, but demoted below the scoped one.
OutlinedButton(
onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) },
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.napplet_consent_allow_always))
}
} else {
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))
}
}
}
}
}
/**
* The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will
* look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext
* for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can
* always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity
* is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in.
*/
@Composable
private fun SignerConsentPreview(info: SignerConsentInfo) {
var showRawData by remember(info) { mutableStateOf(false) }
val accountViewModel = remember { CallSessionBridge.accountViewModel }
val previewNav = remember { EmptyNav() }
val previewNote =
remember(info, accountViewModel) {
val template = info.previewTemplate
val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey
if (template != null && author != null && accountViewModel != null) {
runCatching {
val unsigned = RumorAssembler.assembleRumor<Event>(author, template)
accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author))
}.getOrNull()
} else {
null
}
}
val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank()
if (!hasContent) return
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.medium,
) {
Column(modifier = Modifier.padding(12.dp)) {
if (previewNote != null && accountViewModel != null) {
NoteCompose(
baseNote = previewNote,
isQuotedNote = true,
quotesLeft = 0,
accountViewModel = accountViewModel,
nav = previewNav,
)
} else 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,
)
}
}
}
}
}
/**
* Shown when more than one request is awaiting consent at once (the signer services requests
* concurrently). Lists each with a checkbox — all selected by default — and resolves the selected
* ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]);
* off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render
* (as this list, or the single-request dialog once one remains).
*/
@Composable
private fun BatchedConsentDialog(
pending: List<PendingConsent>,
onResolve: (tokens: List<String>, grant: SignerOpGrant) -> Unit,
onDismiss: () -> Unit,
) {
val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f
val tokens = pending.map { it.token }.toSet()
// Seed all-selected ONCE for the initial batch the user opened. The signer services requests
// concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of
// re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a
// newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently
// re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw.
var selected by remember { mutableStateOf(tokens) }
LaunchedEffect(tokens) { selected = selected intersect tokens }
var rememberChoice by remember { mutableStateOf(false) }
// Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded.
var expanded by remember { mutableStateOf(emptySet<String>()) }
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.padding(vertical = 20.dp)) {
// The sheet is single-account (grouped upstream), so the account is a header, not a
// per-row label. It says WHO every request in this sheet would act as.
val account = pending.first().info
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
account.accountName?.let { name ->
RobohashFallbackAsyncImage(
robot = account.accountPubKey ?: name,
model = account.accountPicture,
contentDescription = null,
modifier = Modifier.size(34.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
style = MaterialTheme.typography.titleLarge,
)
account.accountName?.let { name ->
Text(
stringResource(R.string.nip46_signer_batch_signing_as, name),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
TextButton(
onClick = {
selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet()
},
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
) {
Text(
stringResource(
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
),
style = MaterialTheme.typography.labelLarge,
)
}
Column(
modifier =
Modifier
.weight(1f, fill = false)
.verticalScroll(rememberScrollState()),
) {
pending.forEach { p ->
val isExpanded = p.token in expanded
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable {
expanded = if (isExpanded) expanded - p.token else expanded + p.token
}.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
// Checkbox handles its own tap (select); tapping elsewhere on the row expands.
Checkbox(
checked = p.token in selected,
onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token },
)
Column(modifier = Modifier.weight(1f)) {
Text(
"${p.info.appletTitle} · ${p.info.operationSummary}",
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
if (p.info.contentPreview.isNotBlank()) {
Text(
p.info.contentPreview,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
Icon(
if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
if (isExpanded) {
Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
SignerConsentPreview(p.info)
}
}
}
}
}
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it })
Text(
stringResource(R.string.nip46_signer_batch_remember),
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(Modifier.height(8.dp))
HorizontalDivider()
Spacer(Modifier.height(8.dp))
Button(
onClick = {
val tokens = pending.filter { it.token in selected }
// Per-op remember uses each request's own op; one-time is a single AllowOnce.
if (rememberChoice) {
tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) }
} else {
onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce)
}
},
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
) {
Text(stringResource(R.string.nip46_signer_batch_allow, selected.size))
}
OutlinedButton(
onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) },
enabled = selected.isNotEmpty(),
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error),
) {
Text(stringResource(R.string.nip46_signer_batch_deny, selected.size))
}
}
}
}
}

View File

@@ -0,0 +1,175 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.updateAndGet
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
/** Everything the per-operation consent dialog needs to render. */
data class SignerConsentInfo(
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,
/**
* The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which
* logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the
* robohash avatar fallback when there's no picture.
*/
val accountName: String? = null,
val accountPicture: String? = null,
val accountPubKey: String? = null,
/**
* The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a
* note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and
* non-event ops.
*/
val previewTemplate: EventTemplate<Event>? = null,
/**
* The OTHER party of a decrypt request — whose conversation the app is asking to read — shown as
* an avatar + name. "X wants to read your messages with Alice" is a categorically different
* decision from "X wants to read your private messages", so this must reach the dialog.
* Null for every op that has no counterparty (signing, and the napplet/browser paths).
*/
val counterpartyName: String? = null,
val counterpartyPicture: String? = null,
val counterpartyPubKey: String? = null,
/**
* A NARROWER op the dialog may offer to remember instead of [op] — today only
* [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.DecryptFrom], i.e.
* "always allow, but only for this counterparty". Offered ALONGSIDE the broad "Always allow" so
* the user gets granularity without a prompt per conversation. [narrowOpLabel] is its button text.
*/
val narrowOp: NostrSignerOp? = null,
val narrowOpLabel: String? = null,
)
/** One pending per-operation consent request, as the batched sheet renders it. */
data class PendingConsent(
val token: String,
val info: SignerConsentInfo,
)
/**
* Bridges the broker to the per-operation signer consent UI. The signer services requests
* concurrently (so their prompts can batch), so several requests can await consent at once: they all
* land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog
* or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request
* resolves to [SignerOpGrant.DenyOnce] — fails closed.
*/
object SignerConsentCoordinator {
private val deferreds = ConcurrentHashMap<String, CompletableDeferred<SignerOpGrant>>()
private val _pending = MutableStateFlow<List<PendingConsent>>(emptyList())
/** The live set of requests awaiting the user's decision; the Activity renders this. */
val pending: StateFlow<List<PendingConsent>> = _pending
// A stable notification id (one prompt notification for the whole batch, updated as requests
// arrive) so concurrent requests don't each post their own.
private val batchNotificationId = "nip46-signer-consent".hashCode()
// Guards the surface (post/cancel of the one shared notification) against the pending set so a
// concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it,
// request A could read "pending now empty" and then cancel AFTER request B posted a fresh
// notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout).
private val surfaceLock = Mutex()
suspend fun requestConsent(
context: Context,
info: SignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
deferreds[token] = deferred
surfaceLock.withLock {
_pending.update { it + PendingConsent(token, info) }
// Fast path when Amethyst already owns the foreground: open the dialog directly. When the app
// is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent
// notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and
// observes [pending], and the notification uses a stable id, so concurrent requests just
// refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op.
runCatching {
context.startActivity(
Intent(context, SignerConsentActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
)
}
SignerConsentNotifier.show(
context = context,
activityClass = SignerConsentActivity::class.java,
extraKey = EXTRA_TOKEN,
token = "nip46-signer-consent",
titleRes = R.string.nip46_signer_notif_sign_title,
)
}
return try {
deferred.await()
} finally {
deferreds.remove(token)
surfaceLock.withLock {
// Remove + emptiness check + cancel are one critical section vs. another request's
// add + show, so a fresh notification is never cancelled out from under a live request.
val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } }
if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId)
}
}
}
fun complete(
token: String,
grant: SignerOpGrant,
) {
deferreds[token]?.complete(grant)
}
fun completeAll(
tokens: Collection<String>,
grant: SignerOpGrant,
) {
tokens.forEach { complete(it, grant) }
}
const val EXTRA_TOKEN = "napplet_signer_consent_token"
}

View File

@@ -0,0 +1,141 @@
/*
* 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.connectedApps.consent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
*
* A bare `context.startActivity(...)` from the application context only opens a window while
* Amethyst already owns the foreground. When a signing request arrives over a relay while the app
* is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that
* `startActivity`, so the dialog would never appear and the request would sit until it times out.
*
* A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the
* `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the
* same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for
* incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is
* actively on another app it shows as a heads-up banner they tap to review.
*
* Each coordinator posts one notification keyed by the request token's hash so concurrent requests
* don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed
* out) so no stale prompt lingers.
*/
object SignerConsentNotifier {
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
private fun ensureChannel(context: Context): NotificationChannel {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
val channel =
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.nip46_signer_notif_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
}
manager.createNotificationChannel(channel)
return channel
}
/**
* Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
* request resolves.
*/
fun show(
context: Context,
activityClass: Class<*>,
extraKey: String,
token: String,
titleRes: Int,
): Int {
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
// heads-up notification would just be redundant noise on top of it. Only fall back to the
// full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked.
if (appInForeground()) return NO_NOTIFICATION
val channel = ensureChannel(context)
val notificationId = token.hashCode()
val intent =
Intent(context, activityClass)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.putExtra(extraKey, token)
val pendingIntent =
PendingIntent.getActivity(
context,
notificationId,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val notification =
NotificationCompat
.Builder(context, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(context, titleRes))
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
.setAutoCancel(true)
.setOngoing(true)
.setTimeoutAfter(TIMEOUT_MS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.notify(notificationId, notification)
return notificationId
}
fun cancel(
context: Context,
notificationId: Int,
) {
if (notificationId == NO_NOTIFICATION) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.cancel(notificationId)
}
private fun appInForeground(): Boolean =
// Defensive: the signer consent path only runs in the main process (where Amethyst.instance
// is set), but touching it from the keyless :napplet process would throw. Treat any failure
// as "not foreground" so the notification fallback still fires.
runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false)
private const val NO_NOTIFICATION = Int.MIN_VALUE
private const val TIMEOUT_MS = 120_000L
}

View File

@@ -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.connectedApps.nip46
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.connectedApps.nip46.Nip46ClientInfo
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import kotlinx.coroutines.flow.first
import java.io.File
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
/**
* Single-file DataStore-backed [Nip46ClientStore]. Every connected client's
* display + relay info lives in one `datastore/nip46_clients.preferences_pb`
* file; a SHA-256 prefix of the coordinate is the key so the (already public)
* coordinate is kept alongside for [all]'s reverse lookup. Fields are stored
* individually so no serialization library is needed; [relays] is newline-joined.
*/
class DataStoreNip46ClientStore(
private val filesDir: File,
) : Nip46ClientStore {
constructor(context: Context) : this(context.applicationContext.filesDir)
private val store: DataStore<Preferences> get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb"))
override suspend fun load(coordinate: String): Nip46ClientInfo? {
val prefs = store.data.first()
if (prefs[coordKey(coordinate)] == null) return null
return Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
override suspend fun store(
coordinate: String,
info: Nip46ClientInfo,
) {
store.edit { prefs ->
prefs[coordKey(coordinate)] = coordinate
info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate))
info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate))
info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate))
if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate))
}
}
override suspend fun remove(coordinate: String) {
store.edit { prefs ->
prefs.remove(coordKey(coordinate))
prefs.remove(nameKey(coordinate))
prefs.remove(urlKey(coordinate))
prefs.remove(imageKey(coordinate))
prefs.remove(relaysKey(coordinate))
}
}
override suspend fun all(): Map<String, Nip46ClientInfo> {
val prefs = store.data.first()
val result = mutableMapOf<String, Nip46ClientInfo>()
for ((key, value) in prefs.asMap()) {
if (!key.name.startsWith(COORD_PREFIX)) continue
val coordinate = value as? String ?: continue
result[coordinate] =
Nip46ClientInfo(
name = prefs[nameKey(coordinate)],
url = prefs[urlKey(coordinate)],
image = prefs[imageKey(coordinate)],
relays = prefs[relaysKey(coordinate)].toRelaySet(),
)
}
return result
}
private fun String?.toRelaySet(): Set<String> = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet()
private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}")
private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}")
private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}")
private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}")
private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}")
companion object {
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
stores.computeIfAbsent(file.absolutePath) {
PreferenceDataStoreFactory.create(produceFile = { file })
}
private const val COORD_PREFIX = "coord:"
private fun hash(coordinate: String): String {
val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray())
return digest.take(8).joinToString("") { "%02x".format(it) }
}
}
}

View File

@@ -22,10 +22,14 @@ package com.vitorpamplona.amethyst.favorites
import android.content.Context
import android.util.Log
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.update
import kotlinx.coroutines.launch
import java.io.File
/**
@@ -50,12 +54,28 @@ object BrowserIconRegistry {
@Volatile private var iconDir: File? = null
/** Binds the app context and indexes already-stored icons. Idempotent. */
// Disk work runs here, never on the caller's thread. Both entry points are reached from threads
// that must not block: init() from app startup and record() from the broker's IPC handler, which
// is the main looper — StrictMode flagged the write, and a slow filesystem would have stalled the
// UI while a favicon was saved.
private val io = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* Binds the app context and indexes already-stored icons. Idempotent.
*
* [iconDir] is published synchronously so [iconModelFor] and [record] work immediately; only the
* directory scan is deferred. Until it lands [keys] is empty, so an icon simply renders its
* placeholder for one frame and then recomposes — [keys] is a StateFlow precisely so that arrival
* drives recomposition.
*/
fun init(context: Context) {
if (iconDir != null) return
val dir = File(context.applicationContext.filesDir, DIR).apply { mkdirs() }
val dir = File(context.applicationContext.filesDir, DIR)
iconDir = dir
_keys.value = dir.listFiles()?.mapNotNull { it.name.removeSuffix(PNG).takeIf { n -> n.isNotBlank() } }?.toSet() ?: emptySet()
io.launch {
dir.mkdirs()
_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. */
@@ -66,11 +86,17 @@ object BrowserIconRegistry {
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)
// Fire-and-forget: a favicon is a decoration, and the IPC handler must not wait on disk.
// [keys] updates only after the bytes are actually on disk, so a reader can never be told an
// icon exists before the file backing it does.
io.launch {
try {
dir.mkdirs()
File(dir, key + PNG).writeBytes(bytes)
_keys.update { it + key }
} catch (e: Exception) {
Log.w("BrowserIconRegistry", "Failed to store favicon for $host", e)
}
}
}

View File

@@ -32,6 +32,7 @@ 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.NappletWebViewProfiles
import com.vitorpamplona.amethyst.napplet.WebAppNetworkRegistry
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletBrowserActivity
@@ -92,9 +93,20 @@ object FavoriteAppLauncher {
}
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)
}
NappletBrowserActivity
.intent(
context,
url,
proxyPort,
useTor,
theme = theme,
isFavorite = isFavorite,
// Opaque per-account storage partition, so a web app can't carry one npub's session
// into another. Derived here (the sandbox never sees the pubkey).
webViewProfile = NappletWebViewProfiles.current(),
).apply {
if (context !is Activity) addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}

View File

@@ -27,6 +27,11 @@ import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
@@ -54,6 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDe
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
@@ -90,6 +96,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState
import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState
import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState
import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState
@@ -202,6 +209,7 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.client.paging.RelayLoadingCursors
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -356,6 +364,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.math.BigDecimal
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.cancellation.CancellationException
import com.vitorpamplona.quartz.experimental.nip95.header.thumbhash as nip95thumbhash
import com.vitorpamplona.quartz.experimental.profileGallery.thumbhash as galleryThumbhash
@@ -365,6 +374,14 @@ private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not
/** Name of the default Concord community Admin role minted by "Make admin". */
private const val CONCORD_ADMIN_ROLE = "Admin"
/**
* How often a joined Concord community's stored invite link is re-resolved to check whether
* we were left out of a Refounding (see `recoverStrandedConcordCommunities`). Stranding is
* rare and silent, so this trades detection latency for not turning the revision tick into a
* relay-fetch loop.
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
@OptIn(DelicateCoroutinesApi::class)
@Stable
class Account(
@@ -384,6 +401,8 @@ class Account(
val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null,
val powQueue: () -> PoWPublishQueue? = { null },
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) : IAccount {
private var userProfileCache: User? = null
@@ -438,6 +457,25 @@ class Account(
val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings)
val localRelayList = LocalRelayListState(signer, cache, scope, settings)
/** Connected-Apps signer permission ledger, shared by napplets and the NIP-46 bunker. */
val signerPermissionLedger = NostrSignerPermissionLedger(signerPermissionStore)
/**
* Runs this account as a NIP-46 remote signer for other apps when
* [AccountSettings.nip46SignerEnabled] is on, listening on the inbox relays
* and dispatching to [signer] (see [Nip46SignerState]).
*/
val nip46Signer =
Nip46SignerState(
signer = signer,
client = client,
ledger = signerPermissionLedger,
clientStore = nip46ClientStore,
inboxRelays = nip65RelayList.inboxFlow,
scope = scope,
settings = settings,
)
val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings)
val dmRelayList = DmRelayListState(signer, cache, scope, settings)
@@ -623,6 +661,11 @@ class Account(
val dmRelays = DmInboxRelayState(dmRelayList, nip65RelayList, privateStorageRelayList, localRelayList, scope)
val notificationRelays = NotificationInboxRelayState(nip65RelayList, localRelayList, scope)
// Account-level notification history paging cursors (one scope per account): how far back each
// notification relay has been paged by until+limit. Held here so they share the account's lifetime;
// the history loader ([AccountNotificationsHistoryEoseManager]) binds its orchestrator to these.
val notificationHistory = RelayLoadingCursors()
val cashuWalletState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuWalletState(
pubKey = signer.pubKey,
@@ -1567,12 +1610,26 @@ class Account(
hash: HexKey,
size: Long,
alt: String,
) = blossomServers.createBlossomUploadAuth(hash, size, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomUploadAuth(hash, size, alt, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomMediaAuth(hash, size, alt, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
) = blossomServers.createBlossomDeleteAuth(hash, alt)
servers: List<String> = emptyList(),
) = blossomServers.createBlossomDeleteAuth(hash, alt, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
) = blossomServers.createBlossomListAuth(alt, servers)
suspend fun boost(note: Note) {
val powDifficulty = powDifficultyFor(RepostEvent.KIND)
@@ -2048,6 +2105,21 @@ class Account(
* both explain what went wrong and decide whether a retry could ever help — a
* bundle we can't open (e.g. minted by a newer client) must not strand the user
* on a spinner that retries forever.
*
* A bundle whose `expires_at` has passed is rejected with
* [ConcordInviteResult.Expired]. Expiry is resolved inside
* [ConcordActions.classifyInvite], so it is enforced on every redeem path rather
* than being a field nobody reads.
*
* **This must only ever be called from an explicit user action.** It contacts
* relay URLs carried in the link (chosen by whoever minted it) and publishes a
* Guestbook JOIN signed by this account, so calling it on deep-link arrival would
* leak the user's IP and enroll them without consent — see `ConcordInviteScreen`.
*
* If the resolved community is already in the joined list, this returns
* [ConcordInviteResult.Joined] without re-following or re-announcing a Guestbook
* JOIN, so reopening an old invite for a community you're already in simply takes
* you to it.
*/
suspend fun joinConcordViaInvite(url: String): ConcordInviteResult {
if (!isWriteable()) return ConcordInviteResult.InvalidLink
@@ -2066,11 +2138,20 @@ class Account(
val bundle =
when (val status = ConcordActions.classifyInvite(wraps, parsed.fragment.token)) {
is InviteBundleStatus.Live -> status.invite
is InviteBundleStatus.Expired -> return ConcordInviteResult.Expired
InviteBundleStatus.Revoked -> return ConcordInviteResult.Revoked
InviteBundleStatus.Unreadable -> return ConcordInviteResult.Incompatible
InviteBundleStatus.Absent -> return ConcordInviteResult.NotReachable
}
// Already a member? Just take the user to the community. Re-following and re-announcing a
// Guestbook JOIN (kind 3306) would spam the community relays with a fresh join every time an
// old invite is reopened, so short-circuit to Joined — the screen forwards to the community
// either way ("take me there", not "join again").
if (concordChannelList.liveCommunities.value.any { it.id == bundle.communityId }) {
return ConcordInviteResult.Joined(bundle.communityId)
}
val entry =
ConcordCommunityListEntry(
id = bundle.communityId,
@@ -2081,6 +2162,10 @@ class Account(
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
// Anchor for stranded recovery: keep the link we joined through, domain-agnostic, so a
// Refounding that leaves us out of the recipient set is recoverable later. See
// recoverStrandedConcordCommunities().
inviteRef = ConcordActions.bareInviteRef(url),
)
joinConcordCommunity(entry)
return ConcordInviteResult.Joined(bundle.communityId)
@@ -2289,7 +2374,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2351,12 +2436,12 @@ class Account(
val roleIdHex =
existing?.key ?: run {
val roleId = RandomInstance.bytes(32)
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now())
val roleWrap = ConcordModeration.defineRole(signer, cp, roleId, concordAdminRole(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, roleWrap)
roleId.toHexKey()
}
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now())
val grantWrap = ConcordModeration.grant(signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -2368,17 +2453,26 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now())
val grantWrap = ConcordModeration.grant(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
/**
* If [note] is a Concord channel message whose author this account is allowed to
* ban — the actor is the owner or holds the BAN permission, and the target is
* neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI shows the Ban action only when it would actually take
* effect on fold.
* ban — the actor outranks the target and holds the BAN permission, and the target
* is neither the owner nor the actor — returns `(communityId, memberHex)`. Null
* otherwise, so the UI offers Ban only where we are willing to act.
*
* The rank half is ours alone. CORD-04 rank-gates role grants (`canActOn`) but the
* BANLIST is a single whole-list entity, so neither this client's fold nor Armada's
* rank-checks the *contents* of a banlist edition — both gate only on the author's
* BAN bit (Armada: `banlistGate` → `isAuthorized(.., Permissions.BAN)`, while its
* role path uses the rank-aware `canActOnPosition`). A moderator's ban of an admin
* above them is therefore *accepted* by every client today. Since we cannot refuse
* such a ban without diverging from Armada, we at least refuse to author one — this
* restricts what we write, never what we accept, so it cannot split consensus.
* Enforcing it on the fold needs a spec change; see the QA plan's open findings.
*/
fun concordBanTarget(note: Note): Pair<String, HexKey>? {
val channel = note.inGatherers?.firstNotNullOfOrNull { it as? ConcordChannel } ?: return null
@@ -2392,7 +2486,11 @@ class Account(
?.value
?.authority ?: return null
if (authority.isOwner(author)) return null
val canBan = authority.isOwner(signer.pubKey) || authority.effectivePermissions(signer.pubKey).has(ConcordPermissions.BAN)
// The owner short-circuits rather than going through canActOn: canActOn starts at
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
// the owner on the banlist (see the KDoc) — routing the owner through it would let them be
// locked out of moderating their own community.
val canBan = authority.isOwner(signer.pubKey) || authority.canActOn(signer.pubKey, author, ConcordPermissions.BAN)
return if (canBan) communityId to author else null
}
@@ -2403,7 +2501,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2415,7 +2513,7 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.unban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2429,7 +2527,7 @@ class Account(
/**
* Remove [removed] from the community absolutely (CORD-06 Refounding): ban them,
* roll the `community_root`, re-key every retained member (Guestbook membership
* the privileged roster self) via kind-3303 blobs, and republish the compacted
* observed authors the privileged roster self) via kind-3303 blobs, and republish the compacted
* Control Plane under the new root. A removed member keeps the prior root (so
* their history stays readable) but receives no blob, so they can never decrypt
* anything published after the rotation.
@@ -2454,14 +2552,23 @@ class Account(
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
// first, so each subsequent edition chains onto the updated banlist head.
for (target in removedLower) {
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now())
val banWrap = ConcordModeration.ban(signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, banWrap)
}
// 2. Recipient set: everyone we're keeping — Guestbook joins roster self, minus the
// removed and the already-banned.
// 2. Recipient set: everyone we're keeping, minus the removed and the already-banned.
// Uses allMembers() — Guestbook joins OBSERVED AUTHORS roster owner — not just the
// Guestbook set. Most members never send a Guestbook Join (Amethyst announces one, other
// clients need not), so building the set without observed authors silently expelled every
// member who had only ever posted: they hold no role, receive no blob, and the Refounding
// strands them. That mainly hit cross-client communities, where Armada members are the
// bulk of the roster.
//
// Still a floor, not a census (see allMembers): a member who joined without a Guestbook
// motion, holds no role, and has never posted leaves no trace to find, so a Refounding
// cannot re-key them. Stranded recovery is what gets those members back.
val recipients =
(session.members.value + authority.roleHolders() + state.ownerPubKey + signer.pubKey)
(session.allMembers() + signer.pubKey)
.mapTo(HashSet()) { it.lowercase() }
.apply {
removeAll(removedLower)
@@ -2529,6 +2636,13 @@ class Account(
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
// out of would be unrecoverable.
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
// must survive our rotation write, or we delete their data on every rekey.
residue = entry.residue,
)
sendMyPublicAndPrivateOutbox(concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
@@ -2537,10 +2651,23 @@ class Account(
/**
* Drain any buffered inbound base-rotation rekeys (CORD-06 receive path): for
* each joined community, look for our new root among the kind-3303 wraps seen at
* our next base-rekey address. If a role-authorized rotator (owner or a current
* BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the session
* rebuilds at the new epoch and its next-rekey address moves on, so a stale wrap
* never re-triggers. Called on every Concord revision tick.
* our next base-rekey address. If a role-authorized rotator (owner or a current,
* non-banned BAN-holder) delivered us one, adopt it. Idempotent — once adopted, the
* session rebuilds at the new epoch and its next-rekey address moves on, so a stale
* wrap never re-triggers. Called on every Concord revision tick.
*
* Authority is the roster, never key possession: any non-banned BAN-holder may
* rotate, including for the owner. The owner deliberately does NOT refuse a root
* authored by someone else — refusing would strand the owner alone on the dead
* epoch whenever an admin legitimately rotates, and would diverge from Armada,
* which forks a community across clients. Self-escalation to BAN is prevented
* upstream by the role rank gate in AuthorityResolver.
*
* A rotation carries only (newRoot, newEpoch, rotator); there is no recipient list,
* so a receiver cannot tell who was left out, and a BAN-holder can evict anyone (the
* owner included) by omission — nothing on this receive path can prevent it. The
* cure is after the fact: see [recoverStrandedConcordCommunities], which re-resolves
* the invite link the membership was joined through and merges forward.
*/
private suspend fun drainConcordRekeys() {
if (!isWriteable()) return
@@ -2558,12 +2685,76 @@ class Account(
) ?: continue
if (received.newEpoch <= entry.rootEpoch) continue
val authority = session.state.value?.authority ?: continue
val authorized = authority.isOwner(received.rotator) || authority.effectivePermissions(received.rotator).has(ConcordPermissions.BAN)
// hasPermission, not effectivePermissions: the latter ignores the banlist, so a BAN-holder
// who has themselves been banned could still rotate the whole community.
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
}
}
// Last time we re-resolved each community's invite_ref, so the recovery sweep rides the
// Concord revision tick (which fires on every structural change) without turning it into a
// relay-fetch loop.
private val lastConcordRecoveryCheck = ConcurrentHashMap<String, Long>()
/**
* Stranded recovery (CORD-05/06 receive path). A Refounding carries only
* `(newRoot, newEpoch, rotator)` — **no recipient list** — so a member simply left
* out of the rekey recipient set receives nothing and sits on the dead epoch
* forever while everyone else moves on. This happens to any member, the owner
* included, and [drainConcordRekeys] cannot prevent it: there is no message to
* miss detecting.
*
* The way back is the invite link the membership was joined through
* ([ConcordCommunityListEntry.inviteRef], persisted by [joinConcordViaInvite] and
* carried through every rotation by [adoptConcordRoot]). The community keeps
* re-minting its bundle at that same addressable coordinate, so a bundle there at
* a **strictly higher** epoch than ours proves we were left behind — and carries
* the new root. Same or lower epoch is a no-op. Memberships with no link (direct
* invites, legacy entries) are inert here; that is expected, not an error.
*
* The merge itself ([ConcordActions.recoverStranded]) is epoch-monotonic and keeps
* both the `invite_ref` anchor (so the *next* exclusion is recoverable too) and the
* entry's [HeldRoot]s (so prior-epoch history the member legitimately holds stays
* derivable). We then re-announce the Guestbook at the new epoch, exactly as an
* ordinary rotation does, so the recovered member is visible to whoever refounds
* next instead of being silently dropped again.
*
* Called on the Concord revision tick, but rate-limited per community
* ([RECOVERY_CHECK_INTERVAL_MS]) — a tick with nothing to do costs a map lookup.
*/
private suspend fun recoverStrandedConcordCommunities() {
if (!isWriteable()) return
val now = TimeUtils.nowMillis()
for (entry in concordChannelList.liveCommunities.value) {
val inviteRef = entry.inviteRef ?: continue
val last = lastConcordRecoveryCheck[entry.id]
if (last != null && now - last < RECOVERY_CHECK_INTERVAL_MS) continue
lastConcordRecoveryCheck[entry.id] = now
val parsed = ConcordActions.parseInviteLink(inviteRef) ?: continue
val relays =
(
parsed.fragment.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } +
entry.relays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
).toSet()
if (relays.isEmpty()) continue
val filters = relays.associateWith { listOf(ConcordActions.bundleFilter(parsed.linkSignerPubKey)) }
val wraps = client.fetchAll(filters = filters)
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
sendMyPublicAndPrivateOutbox(concordChannelList.follow(merged))
announceConcordGuestbookJoin(merged, inviteCreator = null, inviteLabel = null)
}
}
/**
* Replace the community metadata (name / icon / description / relays) with a new
* Control-Plane edition. Honored on fold only when this account holds
@@ -2580,7 +2771,7 @@ class Account(
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.editMetadata(signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2598,7 +2789,7 @@ class Account(
if (!isWriteable()) return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2611,8 +2802,16 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// Carry the standing definition forward and change only the name. A ChannelEntity built from
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -2625,8 +2824,15 @@ class Account(
): Boolean {
val session = concordSessions.sessionFor(communityId) ?: return false
if (!isWriteable()) return false
val channel = ChannelEntity(name = name.trim(), deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now())
// Same as rename: preserve the standing flags so a tombstone does not also silently
// reclassify the channel it retires.
val standing =
session.state.value
?.channels
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
val wrap = ConcordModeration.defineChannel(signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -4862,7 +5068,10 @@ class Account(
else -> event.content
}
} else {
event.content
// A read-only (npub-only) account holds no key, so nothing above can run. Returning
// `content` verbatim would push the raw NIP-04/NIP-44 base64 blob straight into the
// UI (chat bubbles, Messages previews, ...). Callers treat null as "not readable".
if (event.hasEncryptedContent()) null else event.content
}
}
@@ -4889,6 +5098,11 @@ class Account(
draftsDecryptionCache.cachedDraft(event)?.content
}
// Encrypted kinds that reached here did so because this account is not writeable
// (every branch above is gated on isWriteable). Their `content` is ciphertext —
// hand back null rather than let the blob render. See cachedDecryptContent.
event != null && event.hasEncryptedContent() -> null
else -> {
event?.content
}
@@ -5319,6 +5533,9 @@ class Account(
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
}
}

View File

@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
@@ -126,6 +127,14 @@ sealed class TopFilter(
@Serializable
object AroundMe : TopFilter(" Around Me ")
/**
* Not a real selection: a sentinel for the "Teleport" chip in the top-nav filter.
* The spinner intercepts it to open the map picker and then applies the chosen
* [Geohash] instead — it is never persisted or dispatched to a feed flow.
*/
@Serializable
object TeleportPicker : TopFilter(" Teleport ")
@Serializable
object Mine : TopFilter(" Mine ")
@@ -185,6 +194,45 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* BUD-04: after uploading a blob to the primary Blossom server, replicate it to
* the user's other configured servers (kind 10063) for redundancy.
*/
val mirrorUploadsToAllServers: MutableStateFlow<Boolean> = MutableStateFlow(true),
/**
* BUD-05: upload media through the server's `/media` endpoint so the server may
* strip metadata and optimize it, instead of the bit-exact `/upload`.
*/
val optimizeMediaOnUpload: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* NIP-46: when true, this account acts as a remote signer (a "bunker") for
* other apps, listening on the user's inbox relays for kind:24133 requests.
* See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState].
*/
val nip46SignerEnabled: MutableStateFlow<Boolean> = MutableStateFlow(false),
/**
* The active pairing secret advertised in this account's `bunker://` URI. An
* app that connects with this secret is accepted and registered as a
* connected app; regenerating it revokes the ability of not-yet-connected
* apps to pair with an old string.
*/
val nip46BunkerSecret: MutableStateFlow<String> = MutableStateFlow(""),
/**
* A dedicated per-account transport keypair (hex private key) for the NIP-46
* bunker. The kind-24133 envelope is wrapped with THIS key, not the account's
* identity key, so the bunker address and on-relay traffic don't reveal which
* user the bunker belongs to (the identity is disclosed only to a connected
* app via `get_public_key`). Generated once and kept stable so the advertised
* `bunker://` address doesn't change.
*/
val nip46TransportKey: MutableStateFlow<String> = MutableStateFlow(""),
/**
* The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying
* stored ephemeral requests across an app restart doesn't make it sign the same request twice —
* matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark,
* a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window.
*/
val nip46SeenRequestIds: MutableStateFlow<Set<String>> = MutableStateFlow(emptySet()),
/**
* NIP-9B opt-in: when true, community feeds drop events whose latest cached
* `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator].
@@ -281,6 +329,9 @@ class AccountSettings(
val defaultRelayAuthPolicy: MutableStateFlow<RelayAuthPolicy> = MutableStateFlow(RelayAuthPolicy.CUSTOM),
val relayGroupViewMode: MutableStateFlow<RelayGroupViewMode> = MutableStateFlow(RelayGroupViewMode.DEFAULT),
val concordViewMode: MutableStateFlow<ConcordViewMode> = MutableStateFlow(ConcordViewMode.DEFAULT),
// Which conversation protocols the Messages inbox loads and shows. A disabled type is both hidden
// from the inbox and dropped from the always-on downloading routes. Defaults to everything on.
val enabledChatFeeds: MutableStateFlow<Set<ChatFeedType>> = MutableStateFlow(ChatFeedType.ALL),
// The per-situation toggles applied under RelayAuthPolicy.CUSTOM.
val relayAuthTrustMyRelaysAndVenues: MutableStateFlow<Boolean> = MutableStateFlow(true),
val relayAuthTrustReadFollows: MutableStateFlow<Boolean> = MutableStateFlow(true),
@@ -317,6 +368,20 @@ class AccountSettings(
}
}
fun isChatFeedEnabled(type: ChatFeedType): Boolean = type in enabledChatFeeds.value
fun setChatFeedEnabled(
type: ChatFeedType,
enabled: Boolean,
) {
val current = enabledChatFeeds.value
val next = if (enabled) current + type else current - type
if (next != current) {
enabledChatFeeds.tryEmit(next)
saveAccountSettings()
}
}
// ---
// Always-on Notification Service
// ---
@@ -582,6 +647,35 @@ class AccountSettings(
}
}
fun changeNip46SignerEnabled(enabled: Boolean) {
if (nip46SignerEnabled.value != enabled) {
nip46SignerEnabled.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeNip46BunkerSecret(secret: String) {
if (nip46BunkerSecret.value != secret) {
nip46BunkerSecret.tryEmit(secret)
saveAccountSettings()
}
}
fun changeNip46TransportKey(hexPrivKey: String) {
if (nip46TransportKey.value != hexPrivKey) {
nip46TransportKey.tryEmit(hexPrivKey)
saveAccountSettings()
}
}
/** Replaces the recent serviced-request id set (already bounded by the caller). */
fun changeNip46SeenRequestIds(ids: Set<String>) {
if (nip46SeenRequestIds.value != ids) {
nip46SeenRequestIds.tryEmit(ids)
saveAccountSettings()
}
}
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
@@ -589,6 +683,20 @@ class AccountSettings(
}
}
fun changeMirrorUploadsToAllServers(enabled: Boolean) {
if (mirrorUploadsToAllServers.value != enabled) {
mirrorUploadsToAllServers.tryEmit(enabled)
saveAccountSettings()
}
}
fun changeOptimizeMediaOnUpload(enabled: Boolean) {
if (optimizeMediaOnUpload.value != enabled) {
optimizeMediaOnUpload.tryEmit(enabled)
saveAccountSettings()
}
}
fun updateAddClientTag(add: Boolean): Boolean =
if (syncedSettings.security.updateAddClientTag(add)) {
saveAccountSettings()

View File

@@ -47,6 +47,13 @@ sealed interface ConcordInviteResult {
*/
data object Revoked : ConcordInviteResult
/**
* The bundle opened fine, but its `expires_at` has passed. Retrying can't help —
* unlike [Revoked] the owner didn't retire the link, it simply timed out, so the
* user's next step is to ask for a fresh one.
*/
data object Expired : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose

View File

@@ -1626,9 +1626,9 @@ object LocalCache : ILocalCache, ICacheProvider {
): Boolean {
val note = getOrCreateNote(event.id)
// Approval notes are badge-rendered directly in community feeds
// (BadgeBox has no repost-style indirection for them), so without
// this attribution their relay list stays empty forever.
// Approval notes render their own relay list directly in community feeds
// (there is no repost-style indirection to a replyTo for them), so without
// this attribution the "accepted by relays" gallery line stays empty forever.
if (relay != null) {
getOrCreateUser(event.pubKey).addRelayBeingUsed(relay, event.createdAt)
note.addRelay(relay)
@@ -2006,9 +2006,20 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event == null) return
if (relay != null) {
// Normal arrival: the group only exists on its host relay and the
// filters are host-pinned, so the serving relay is the group's key.
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addNote(note, relay)
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
// Normal arrival: the group's host-pinned filters served it, so the serving relay IS the
// group's key and its channel already exists. Fast O(1) path — no scan.
existing.addNote(note, relay)
} else {
// No channel keyed to the serving relay: this may be a stray from a NON-host relay (e.g. a
// quoted kind-9 resolved by id). Redirect it to the group's single confirmed host rather
// than mint a phantom channel the group's screens never read (the serving-relay hazard);
// fall back to the serving-relay key when there is no single host (new/ambiguous group).
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addNote(note, relay)
}
} else {
// Our own optimistic send has no provenance relay, so we can't build the (groupId,
// relay) key. Attach only when a SINGLE open channel has this group id (the room being
@@ -2022,6 +2033,12 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
/** Candidate group channels for the [redirectStrayRelayGroupContent] slow path — one scan by group id. */
private fun relayGroupCandidatesFor(groupId: String): List<RelayGroupTargetCandidate> =
relayGroupChannels
.filter { key, _ -> key.id == groupId }
.map { RelayGroupTargetCandidate(it.groupId, it.hasRelaySignedState()) }
/**
* Same routing as [attachToRelayGroupIfScoped] but for kind-11 threads, which
* are kept in a separate collection from the chat timeline so the two content
@@ -2036,7 +2053,15 @@ object LocalCache : ILocalCache, ICacheProvider {
if (note.event == null) return
if (relay != null) {
getOrCreateRelayGroupChannel(GroupId(groupId, relay)).addThread(note)
val exact = GroupId(groupId, relay)
val existing = getRelayGroupChannelIfExists(exact)
if (existing != null) {
existing.addThread(note)
} else {
// Same serving-relay hazard as the chat path: prefer the single confirmed host over a phantom.
val target = redirectStrayRelayGroupContent(relayGroupCandidatesFor(groupId)) ?: exact
getOrCreateRelayGroupChannel(target).addThread(note)
}
} else {
// See attachToRelayGroupIfScoped: only attach when the group id is unambiguous.
relayGroupChannels

View File

@@ -21,51 +21,71 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.utils.Log
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableMap
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonContentPolymorphicSerializer
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "resourceType",
)
@JsonSubTypes(
JsonSubTypes.Type(value = Practitioner::class, name = "Practitioner"),
JsonSubTypes.Type(value = Patient::class, name = "Patient"),
JsonSubTypes.Type(value = Bundle::class, name = "Bundle"),
JsonSubTypes.Type(value = VisionPrescription::class, name = "VisionPrescription"),
)
/**
* FHIR resources are polymorphic on the `resourceType` string. We only model the
* handful of types Amethyst renders; anything else (and any resource with a
* missing/unrecognized type) decodes into [UnknownResource] so a mixed [Bundle]
* never fails to parse just because it carries a type we don't know about.
*/
object ResourceSerializer : JsonContentPolymorphicSerializer<Resource>(Resource::class) {
override fun selectDeserializer(element: JsonElement): DeserializationStrategy<Resource> =
when (element.jsonObject["resourceType"]?.jsonPrimitive?.content) {
"Practitioner" -> Practitioner.serializer()
"Patient" -> Patient.serializer()
"Bundle" -> Bundle.serializer()
"VisionPrescription" -> VisionPrescription.serializer()
else -> UnknownResource.serializer()
}
}
@Serializable(with = ResourceSerializer::class)
@Stable
open class Resource(
var resourceType: String? = null,
var id: String = "",
)
abstract class Resource {
abstract val resourceType: String?
abstract val id: String
}
/** Fallback for any FHIR resourceType we don't model. */
@Serializable
@Stable
class UnknownResource(
override val resourceType: String? = null,
override val id: String = "",
) : Resource()
@Serializable
@Stable
class Practitioner(
resourceType: String? = null,
id: String = "",
override val resourceType: String? = null,
override val id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource(resourceType, id)
) : Resource()
@Serializable
@Stable
class Patient(
resourceType: String? = null,
id: String = "",
override val resourceType: String? = null,
override val id: String = "",
var active: Boolean? = null,
var name: ArrayList<HumanName> = arrayListOf(),
var gender: String? = null,
) : Resource(resourceType, id)
) : Resource()
@Serializable
@Stable
class HumanName(
var use: String? = null,
@@ -75,19 +95,21 @@ class HumanName(
fun assembleName(): String = given.joinToString(" ") + " " + family
}
@Serializable
@Stable
class Bundle(
resourceType: String? = null,
id: String = "",
override val resourceType: String? = null,
override val id: String = "",
var type: String? = null,
var created: String? = null,
var entry: List<Resource> = arrayListOf(),
) : Resource(resourceType, id)
) : Resource()
@Serializable
@Stable
class VisionPrescription(
resourceType: String? = null,
id: String = "",
override val resourceType: String? = null,
override val id: String = "",
var status: String? = null,
var created: String? = null,
var patient: Reference? = Reference(),
@@ -95,7 +117,7 @@ class VisionPrescription(
var dateWritten: String? = null,
var prescriber: Reference? = Reference(),
var lensSpecification: List<LensSpecification> = arrayListOf(),
) : Resource(resourceType, id) {
) : Resource() {
fun glasses() = lensSpecification.filter { it.product == "lens" }
fun contacts() = lensSpecification.filter { it.product == "contacts" }
@@ -109,6 +131,7 @@ class VisionPrescription(
fun contactsLeftEyes() = lensSpecification.filter { it.product == "contacts" && it.eye == "left" }
}
@Serializable
@Stable
class LensSpecification(
var product: String? = null,
@@ -129,12 +152,14 @@ class LensSpecification(
var note: String? = null,
)
@Serializable
@Stable
class Prism(
var amount: Double? = null,
var base: String? = null,
)
@Serializable
class Reference(
var reference: String? = null,
)
@@ -156,12 +181,22 @@ fun findReferenceInDb(
}
}
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
val mapper =
jacksonObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
/**
* Lenient FHIR JSON reader: unknown keys are ignored (implementations routinely add
* their own fields) and missing keys fall back to the property defaults, so we parse
* as much of a resource as we can rather than rejecting the whole document.
*/
val FhirJson =
Json {
ignoreUnknownKeys = true
isLenient = true
explicitNulls = false
coerceInputValues = true
}
return try {
val resource = mapper.readValue<Resource>(json)
fun parseResourceBundleOrNull(json: String): FhirElementDatabase? =
try {
val resource = FhirJson.decodeFromString(ResourceSerializer, json)
val db =
when (resource) {
@@ -182,4 +217,3 @@ fun parseResourceBundleOrNull(json: String): FhirElementDatabase? {
Log.e("RenderEyeGlassesPrescription", "Parser error", e)
null
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.quartz.nip29RelayGroups.GroupId
/**
* A candidate group channel when routing a stray group-scoped content event: its [key] and whether it is a
* confirmed host (has received relay-signed state). See [redirectStrayRelayGroupContent].
*/
data class RelayGroupTargetCandidate(
val key: GroupId,
val hasRelaySignedState: Boolean,
)
/**
* Resolves the **serving-relay hazard**. A group-scoped content event (kind-9 chat, poll, kind-11 thread…)
* is keyed to its group channel by the relay that served it, because a NIP-29 event doesn't carry its host
* relay. That is correct for the group's own host-pinned subscriptions, but a message resolved from a
* **non-host** relay — e.g. a quoted kind-9 fetched by id during missing-event resolution — would be filed
* under a channel keyed to that stranger relay, one the group's own screens never read, so the message
* silently vanishes.
*
* Called only when there is **no** channel keyed to the serving relay for this group id (the fast, common
* path attaches directly and never gets here). It picks the group's single confirmed **host** channel — one
* that has received relay-signed state — to attach the stray to instead. Returns that host key, or null when
* there is no single confirmed host (a genuinely new group on the serving relay, or an id ambiguous across
* several hosts), in which case the caller keeps the serving-relay key as today's best effort.
*
* A phantom channel (one minted from an earlier stray) never has relay-signed state, so it can never be
* chosen here — the redirect only ever lands on a real host, never on another phantom. This makes the fix
* strictly safe: it can redirect a stray to a known host, but never divert a message away from one.
*/
fun redirectStrayRelayGroupContent(candidates: List<RelayGroupTargetCandidate>): GroupId? = candidates.filter { it.hasRelaySignedState }.singleOrNull()?.key

View File

@@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.model.accountsCache
import android.content.ContentResolver
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -66,9 +70,16 @@ class AccountCacheState(
val powQueue: () -> PoWPublishQueue? = { null },
/** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */
val meterSigner: (NostrSigner) -> NostrSigner = { it },
/** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */
val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
/** App-global store of connected NIP-46 client display + relay info. */
val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) {
val accounts = MutableStateFlow<Map<HexKey, Account>>(emptyMap())
/** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */
private val loadLock = Any()
fun removeAccount(pubkey: HexKey) {
accounts.update { existingAccounts ->
val oldValue = existingAccounts[pubkey]
@@ -179,6 +190,22 @@ class AccountCacheState(
val cached = accounts.value[signer.pubKey]
if (cached != null) return cached
// Serialize construction: the UI login path and the always-on service's preload race
// to load the same account on cold start. Without the lock both see a null cache and
// both build an Account — the loser is never cancelled, leaving a zombie whose
// Nip46SignerState answers bunker requests with a NostrSignerExternal no Activity
// ever registers a launcher on (every sign fails "No activity to launch from"),
// while duplicating consent prompts and racing error replies to NIP-46 clients.
return synchronized(loadLock) {
accounts.value[signer.pubKey]?.let { return it }
createAccount(signer, accountSettings)
}
}
private fun createAccount(
signer: NostrSigner,
accountSettings: AccountSettings,
): Account {
val signerWithClientTag =
NostrSignerWithClientTag(
inner = meterSigner(signer),
@@ -258,6 +285,8 @@ class AccountCacheState(
marmotKeyPackageStore = marmotKeyPackageStore,
powQueue = powQueue,
relayAuthPermissionStore = relayAuthPermissionStore,
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
).also { newAccount ->
accounts.update { existingAccounts ->
existingAccounts.plus(Pair(signer.pubKey, newAccount))

View File

@@ -0,0 +1,56 @@
/*
* 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.nip46Signer
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
/** One serviced NIP-46 request, for the "recent activity" feed. */
data class Nip46ActivityEntry(
val atSeconds: Long,
val clientPubKey: HexKey,
/** The NIP-46 method (`sign_event`, `nip44_encrypt`, `get_public_key`, …). */
val method: String,
/** Event kind for a `sign_event`, else `null`. */
val kind: Int? = null,
/** `null` when the request succeeded; the error string when it failed or was denied. */
val error: String? = null,
) {
val ok: Boolean get() = error == null
}
/**
* A bounded, newest-first, in-memory log of the requests this account's signer has serviced, so the
* user can see what apps are actually doing. Not persisted across app restarts (it is a live feed,
* not an audit trail); it survives service restarts because it lives on the account's signer state.
*/
class Nip46ActivityLog(
private val capacity: Int = 100,
) {
private val _entries = MutableStateFlow<List<Nip46ActivityEntry>>(emptyList())
val entries: StateFlow<List<Nip46ActivityEntry>> = _entries
fun record(entry: Nip46ActivityEntry) {
_entries.update { (listOf(entry) + it).take(capacity) }
}
}

View File

@@ -0,0 +1,192 @@
/*
* 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.nip46Signer
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.napplet.label
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import kotlinx.coroutines.withTimeoutOrNull
/**
* Bridges the (KMP, headless) [com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer]
* to the interactive consent UI. It reuses the SAME dialogs the napplet/browser signer path uses —
* [SignerConnectCoordinator] (first-connect trust picker) and [SignerConsentCoordinator]
* (per-operation allow/deny) — so a NIP-46 remote app prompts through one consistent surface, and
* the user's "remember" choices land in the same [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger].
*
* Runs only in the main process (the signer never runs in `:napplet`), so [Amethyst.instance] is set;
* the coordinators launch their Activity from the application context.
*/
object Nip46ConsentBridge {
/**
* Upper bound on how long a consent prompt may block the signer's single-consumer loop. A user who
* ignores the dialog eventually fails the request closed (deny / declined) instead of wedging the
* signer for every other client whose requests queue behind that one blocked prompt.
*/
private const val CONSENT_TIMEOUT_MS = 120_000L
/** First-connect consent: show the app's self-declared identity and let the user pick a trust level. */
suspend fun requestConnect(
coordinate: String,
clientPubKey: HexKey,
request: BunkerRequestConnect,
): AppConnectResult {
val context = Amethyst.instance.appContext
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "")
// The identity being connected to lives in the coordinate; show it as an avatar + name.
val face = accountFace(coordinate)
val info =
SignerConnectInfo(
appletTitle = title,
coordinate = coordinate,
domain = domain,
iconUrl = meta?.image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
)
// Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't
// hold the single-consumer loop hostage against every other client.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConnectCoordinator.requestConnect(context, info)
} ?: AppConnectResult.Cancelled
}
/**
* First-connect consent for the client-initiated (`nostrconnect://`) flow: like [requestConnect]
* but built from the pasted/scanned offer, and — crucially — it surfaces the app's declared
* [requestedOps] so the user gives informed consent before those ops are pre-granted. Returns the
* user's [AppConnectResult] (or [AppConnectResult.Cancelled] if the prompt is never answered).
*/
suspend fun requestNostrConnectConsent(
coordinate: String,
name: String?,
url: String?,
image: String?,
requestedOps: List<NostrSignerOp>,
): AppConnectResult {
val context = Amethyst.instance.appContext
val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("") ?: "")
val face = accountFace(coordinate)
val info =
SignerConnectInfo(
appletTitle = title,
coordinate = coordinate,
domain = domain,
iconUrl = image,
accountName = face.name,
accountPicture = face.picture,
accountPubKey = face.pubKey,
requestedPermissions = requestedOps.map { it.label(context) },
)
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConnectCoordinator.requestConnect(context, info)
} ?: AppConnectResult.Cancelled
}
/**
* Per-operation consent: describe the request and await the user's grant.
*
* For a decrypt request this DECRYPTS FIRST and shows the resulting plaintext, together with the
* counterparty the conversation is with. That is what makes the decision reviewable: without it
* the dialog said only "wants to read your private messages" with no way to tell one request from
* another. Decryption is local — [signer] runs on this device and nothing leaves it unless the
* user approves — and it is bounded by [Nip46ConsentInfoBuilder.DECRYPT_PREVIEW_TIMEOUT_MS] so a slow or failing signer
* degrades to an explanatory message instead of hanging or blanking the prompt.
*/
suspend fun requestOp(
coordinate: String,
clientPubKey: HexKey,
op: NostrSignerOp,
request: BunkerRequest,
signer: NostrSigner,
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val consentInfo =
Nip46ConsentInfoBuilder.build(
coordinate = coordinate,
title = title,
iconUrl = info?.image,
op = op,
request = request,
account = accountFace(coordinate),
faceOf = ::userFace,
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
// Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage.
return withTimeoutOrNull(CONSENT_TIMEOUT_MS) {
SignerConsentCoordinator.requestConsent(context, consentInfo)
} ?: SignerOpGrant.DenyOnce
}
/**
* Performs the local decryption behind the decrypt preview with the account's own signer. Errors
* and timeouts are handled by [Nip46ConsentInfoBuilder]; this only maps the request to a call.
*/
private suspend fun decryptWithAccountSigner(
signer: NostrSigner,
request: BunkerRequest,
): String? =
when (request) {
is BunkerRequestNip04Decrypt -> signer.nip04Decrypt(request.ciphertext, request.pubKey)
is BunkerRequestNip44Decrypt -> signer.nip44Decrypt(request.ciphertext, request.pubKey)
else -> null
}
/** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */
private fun accountFace(coordinate: String): SignerFace {
val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate)
val user = pubKey?.let { LocalCache.getUserIfExists(it) }
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
/** Cached profile for a counterparty; the builder supplies the shortened-npub fallback. */
private fun userFace(pubKey: HexKey): SignerFace {
val user = LocalCache.getUserIfExists(pubKey)
return SignerFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey)
}
}

View File

@@ -0,0 +1,176 @@
/*
* 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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.decryptCounterparty
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.withTimeoutOrNull
/** Avatar + display name for one pubkey, as the consent dialogs render it. */
data class SignerFace(
val name: String?,
val picture: String?,
val pubKey: String?,
)
/**
* The user-visible strings the builder needs, injected rather than read from `R.string` so the
* builder itself carries no Android dependency and can be unit-tested.
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
/**
* Builds the [SignerConsentInfo] for one NIP-46 per-operation prompt.
*
* Split out of [Nip46ConsentBridge] (which owns the Android `Context`/`LocalCache` lookups) so the
* decisions that matter for safety are testable without an emulator:
* - a decrypt request is DECRYPTED FIRST and the plaintext becomes the preview, honouring the
* contract the dialog documented but never implemented;
* - a decrypt that cannot be decrypted still produces a populated dialog, never a blank one;
* - the counterparty label is never empty — it degrades to a shortened npub, never to nothing.
*/
object Nip46ConsentInfoBuilder {
/** Characters of plaintext/content shown inline before the "show more" toggle takes over. */
const val PREVIEW_MAX_CHARS = 160
/**
* Upper bound on the pre-consent decryption. Short on purpose: the preview is a nicety, the
* prompt is not, so a signer that stalls (e.g. an external NIP-55 app that is not responding)
* must not delay the dialog.
*/
const val DECRYPT_PREVIEW_TIMEOUT_MS = 8_000L
suspend fun build(
coordinate: String,
title: String,
iconUrl: String?,
op: NostrSignerOp,
request: BunkerRequest,
account: SignerFace,
/** Resolves a pubkey to a cached profile; the builder supplies its own npub fallback. */
faceOf: (HexKey) -> SignerFace,
strings: Nip46ConsentStrings,
/** Performs the local decryption. May fail, return null, or hang — all are handled. */
decrypt: suspend (BunkerRequest) -> String?,
): SignerConsentInfo {
val counterparty = request.decryptCounterparty()
val plaintext = if (counterparty != null) decryptPreview(request, decrypt, strings.decryptFailed) else null
val preview =
when {
request is BunkerRequestSign ->
request.event.content
.take(PREVIEW_MAX_CHARS)
.trim()
plaintext != null -> plaintext.take(PREVIEW_MAX_CHARS).trim()
else -> ""
}
val rawData =
when {
request is BunkerRequestSign -> JacksonMapper.toJsonPretty(request.event)
// Only worth a "show more" toggle when the preview actually truncated it.
plaintext != null && plaintext.length > PREVIEW_MAX_CHARS -> plaintext
else -> ""
}
// A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to
// the broad "always allow", instead of only the all-conversations-forever choice.
val narrowOp = request.toNarrowSignerOp()
val counterpartyFace = counterparty?.let { face(it, faceOf) }
return SignerConsentInfo(
appletTitle = title,
coordinate = coordinate,
op = op,
// For decrypt this names the counterparty ("read your private messages with Alice").
operationSummary = strings.opLabel(narrowOp ?: op),
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
accountName = account.name,
accountPicture = account.picture,
accountPubKey = account.pubKey,
previewTemplate = (request as? BunkerRequestSign)?.event,
counterpartyName = counterpartyFace?.name,
counterpartyPicture = counterpartyFace?.picture,
counterpartyPubKey = counterparty,
narrowOp = narrowOp,
narrowOpLabel = counterpartyFace?.name?.let { strings.allowAlwaysFor(it) },
)
}
/**
* Decrypts the message the app asked to read. Never throws and never hangs: a signer that fails,
* refuses, returns nothing, or takes too long yields [failureText], because a request whose
* ciphertext we cannot even read is itself worth showing — a blank dialog is not.
*/
private suspend fun decryptPreview(
request: BunkerRequest,
decrypt: suspend (BunkerRequest) -> String?,
failureText: String,
): String =
withTimeoutOrNull(DECRYPT_PREVIEW_TIMEOUT_MS) {
try {
decrypt(request)?.ifBlank { null }
} catch (e: CancellationException) {
// Includes this block's own timeout — must propagate so withTimeoutOrNull sees it.
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "decrypt preview failed: ${e.message}" }
null
}
} ?: failureText
/** [faceOf], but with a guaranteed non-blank name (shortened npub when the user isn't cached). */
private fun face(
pubKey: HexKey,
faceOf: (HexKey) -> SignerFace,
): SignerFace {
val resolved = runCatching { faceOf(pubKey) }.getOrNull()
return SignerFace(
name = resolved?.name?.ifBlank { null } ?: shortIdentifier(pubKey),
picture = resolved?.picture,
pubKey = pubKey,
)
}
/** A shortened npub for an uncached pubkey; falls back to the hex prefix if it isn't valid hex. */
fun shortIdentifier(pubKey: HexKey): String {
val npub = runCatching { NPub.create(pubKey) }.getOrNull()
return if (!npub.isNullOrBlank()) npub.take(12) + "" else pubKey.take(12) + ""
}
}

View File

@@ -0,0 +1,401 @@
/*
* 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.nip46Signer
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor
import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
/** How many recently-serviced request ids to persist for cross-restart replay dedup. */
private const val MAX_SEEN_IDS = 128
/**
* Auto-forget a connected app after this long with no activity. Each connected NIP-46 app makes the
* signer hold a background relay subscription indefinitely, so an app paired once and abandoned would
* leak a relay connection forever; pruning idle apps bounds that growth. Last-used is stamped on
* connect and on every serviced request, so an app still in use is never pruned.
*/
private const val IDLE_PRUNE_SECONDS = 7 * 24 * 60 * 60L
/**
* Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other
* apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a
* [NostrConnectSignerService] listens on the user's inbox relays (plus any relays
* pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests.
*
* Two keys are kept apart: a dedicated local [transportSigner] wraps/unwraps the
* kind-24133 envelope (so the bunker address never reveals the user, and an
* external NIP-55 account pays no IPC cost for envelope crypto), while the actual
* sign/encrypt/decrypt and `get_public_key` use the account's identity [signer] —
* a local key or a NIP-55 external app, whichever the user logged in with.
*
* Every request is gated by [Nip46PermissionAuthorizer], i.e. the same
* "Connected Apps" trust ledger that governs napplets and web origins: a remote
* client is a connected app under the coordinate `nip46:<signerPubKey>:<clientPubKey>`.
*
* The listener restarts whenever the enabled flag or the relay set changes
* ([collectLatest] cancels the previous run), so editing inbox relays or toggling
* the feature takes effect immediately.
*/
class Nip46SignerState(
val signer: NostrSigner,
val client: INostrClient,
val ledger: NostrSignerPermissionLedger,
val clientStore: Nip46ClientStore,
val inboxRelays: StateFlow<Set<NormalizedRelayUrl>>,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
/** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */
private val extraRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(emptySet())
/** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */
val activityLog = Nip46ActivityLog()
/**
* A bounded, recently-serviced set of kind-24133 event ids, persisted so a relay replaying stored
* requests after an app restart is deduped by exact id (see [NostrConnectSignerService.initialSeen]).
* Touched only from the service's single consumer coroutine, so it needs no synchronization.
*/
private val recentHandledIds = LinkedHashSet(settings.nip46SeenRequestIds.value)
private fun rememberHandledId(eventId: HexKey) {
if (!recentHandledIds.add(eventId)) return
while (recentHandledIds.size > MAX_SEEN_IDS) {
recentHandledIds.iterator().let {
it.next()
it.remove()
}
}
settings.changeNip46SeenRequestIds(recentHandledIds.toSet())
}
/**
* The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key
* unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for,
* and (unlike the identity signer) an external NIP-55 account pays no IPC cost for envelope crypto.
* Generated + persisted lazily on first use so accounts that never enable the signer mint nothing.
*
* Rebuilt from the persisted key on every call rather than cached, so [rotateAddress] takes effect:
* the service-restart trigger includes [AccountSettings.nip46TransportKey], and this reads the
* current value — deriving a keypair from stored bytes is cheap enough for the per-restart cost.
*/
private fun transportSigner(): NostrSignerInternal = NostrSignerInternal(KeyPair(ensureTransportKeyBytes()))
/** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */
val listeningRelays: StateFlow<Set<NormalizedRelayUrl>> =
combine(inboxRelays, extraRelays) { inbox, extra -> inbox + extra }
.stateIn(scope, SharingStarted.Eagerly, inboxRelays.value)
private val authorizer =
Nip46PermissionAuthorizer(
ledger = ledger,
signerPubKey = signer.pubKey,
validateSecret = { clientPubKey, offered ->
// A new app pairs with the current bunker secret; an already-connected app
// re-authenticates by identity (it already holds a trust level in the ledger).
val secret = settings.nip46BunkerSecret.value
(secret.isNotEmpty() && offered == secret) ||
ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey))
},
onConnected = { clientPubKey, request ->
// A bunker-flow client talks to us on the inbox relays we always listen on, so we
// only persist its self-declared display metadata (never as authorization — just a label).
val meta = request.clientMetadata
if (meta != null && !meta.isEmpty()) {
clientStore.store(
Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey),
Nip46ClientInfo(name = meta.name, url = meta.url, image = meta.image),
)
}
},
clientStore = clientStore,
// A forgotten client's relays are gone from the store now; recompute the listen set so we
// stop listening on them this session instead of waiting for a restart.
onDisconnected = { refreshExtraRelaysFromStore() },
// Interactive consent through the shared signer dialogs: a trust-level picker on first
// connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds,
// decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing.
connectConsent = Nip46ConsentBridge::requestConnect,
// The account's own signer goes to the bridge so a decrypt request can be decrypted
// BEFORE the prompt — the dialog shows the actual plaintext instead of an opaque
// "wants to read your private messages". Local only; nothing is disclosed until approval.
opConsent = { coordinate, clientPubKey, op, request ->
Nip46ConsentBridge.requestOp(coordinate, clientPubKey, op, request, signer)
},
)
init {
// extraRelays is a live projection of the persisted client store (the nostrconnect apps' own
// relays). Load it on start so paired apps stay reachable across restarts; it is refreshed
// whenever a client connects or is forgotten (bunker-flow apps use the inbox relays instead).
// Prune apps idle past IDLE_PRUNE_SECONDS first so we don't re-subscribe to a relay only an
// abandoned app used — forget() already refreshes extraRelays, and we refresh again in case
// nothing was pruned.
scope.launch(Dispatchers.IO) {
runCatching { authorizer.pruneIdle(IDLE_PRUNE_SECONDS) }
.onFailure { Log.w("NIP46Signer") { "idle prune failed: ${it.message}" } }
refreshExtraRelaysFromStore()
}
scope.launch(Dispatchers.IO) {
combine(settings.nip46SignerEnabled, listeningRelays, settings.nip46TransportKey) { enabled, relays, transportKey ->
Triple(enabled, relays, transportKey)
}
// Inbox/relay/key StateFlows can re-emit an identical value; without this every duplicate
// would tear the subscription down and re-open it on every relay for no reason. Including
// the transport key here makes rotateAddress() re-subscribe under the fresh key.
.distinctUntilChanged()
.collectLatest { (enabled, relays, _) ->
if (!enabled) return@collectLatest
if (!signer.isWriteable()) {
Log.w("NIP46Signer") { "signer not writeable; cannot host a bunker" }
return@collectLatest
}
if (relays.isEmpty()) return@collectLatest
// Envelope wrapped with the local transport key; the actual work (and get_public_key)
// uses the account's identity signer inside the processor.
val processor = BunkerRequestProcessor(signer, { listeningRelays.value }, authorizer)
val service =
NostrConnectSignerService(
client = client,
transportSigner = transportSigner(),
processor = processor,
relays = relays,
onServiced = { request, clientPubKey, error ->
Log.d("NIP46Signer") { "${request.method} from ${clientPubKey.take(8)}… → ${error ?: "ok"}" }
activityLog.record(
Nip46ActivityEntry(
atSeconds = TimeUtils.now(),
clientPubKey = clientPubKey,
method = request.method,
kind = (request as? BunkerRequestSign)?.event?.kind,
error = error,
),
)
},
// Seed dedup with the ids we serviced last session so an app restart doesn't
// re-sign a relay's replay of the same stored requests (matched by exact id).
initialSeen = settings.nip46SeenRequestIds.value,
onHandledId = { id -> rememberHandledId(id) },
)
service.run()
}
}
}
/** Whether the account is currently advertising itself as a signer. */
val enabled: StateFlow<Boolean> get() = settings.nip46SignerEnabled
fun setEnabled(enabled: Boolean) {
if (enabled) {
// Settle the secret and transport key BEFORE flipping the flag, so the service-restart
// trigger sees the final transport key on its first emission (no throwaway double-start).
ensureSecret()
ensureTransportKeyBytes()
}
settings.changeNip46SignerEnabled(enabled)
}
/** The `bunker://<transport-pubkey>?relay=…&secret=…` string to paste into another app. Generates keys/secret if needed. */
fun bunkerUri(): String {
val secret = ensureSecret()
// Advertise the transport key, not the identity key, so the address doesn't reveal who we are.
return NostrConnectURI.buildBunker(transportSigner().pubKey, inboxRelays.value, secret)
}
/** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */
fun regenerateSecret(): String {
val fresh = RandomInstance.randomChars(32)
settings.changeNip46BunkerSecret(fresh)
return fresh
}
/**
* The anti-spam "burn it down" action: mints a brand-new transport key (and pairing secret), so
* the old `bunker://` address goes dark — anyone who had it (a spammer included) can no longer
* reach us, and every app talking to the old transport pubkey is dropped. The running service
* re-subscribes under the new key because [AccountSettings.nip46TransportKey] feeds the restart
* trigger. Legit apps re-pair by re-scanning the new address; their trust survives because the
* Connected-Apps coordinate keys off the stable identity pubkey, not the transport key.
*/
fun rotateAddress(): String {
val fresh = KeyPair()
settings.changeNip46TransportKey(fresh.privKey!!.toHexKey())
regenerateSecret()
return NostrConnectURI.buildBunker(fresh.pubKey.toHexKey(), inboxRelays.value, settings.nip46BunkerSecret.value)
}
/** Recomputes [extraRelays] from the persisted client store — the source of truth for nostrconnect relays. */
private suspend fun refreshExtraRelaysFromStore() {
extraRelays.value =
clientStore
.all()
.filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) }
.values
.flatMap { it.relays }
.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }
.toSet()
}
/**
* Forgets a connected client (the user's "Forget" action): revokes its grant, drops its stored
* metadata/relays, and stops listening on relays that only it used. Same path as a client-sent
* `logout`, so both are consistent.
*/
suspend fun forgetClient(clientPubKey: HexKey) = authorizer.forget(clientPubKey)
/** Returns the current pairing secret, generating and persisting one the first time. */
private fun ensureSecret(): String {
val current = settings.nip46BunkerSecret.value
if (current.isNotEmpty()) return current
val fresh = RandomInstance.randomChars(32)
settings.changeNip46BunkerSecret(fresh)
return fresh
}
/** The transport private key bytes, generating and persisting a fresh keypair the first time (or if corrupt). */
private fun ensureTransportKeyBytes(): ByteArray {
val stored = settings.nip46TransportKey.value
val existing = stored.takeIf { it.length == 64 }?.let { runCatching { it.hexToByteArray() }.getOrNull() }
if (existing != null) return existing
val fresh = KeyPair()
settings.changeNip46TransportKey(fresh.privKey!!.toHexKey())
return fresh.privKey!!
}
/**
* The client-initiated (`nostrconnect://`) pairing flow: parse a client's
* offer, send the connect ack that echoes its secret (so the client learns
* our signer pubkey), register it as a connected app, and start listening on
* its relays. Enables the signer if it was off.
*/
suspend fun connectViaNostrConnect(uri: String): ConnectResult {
val offer = NostrConnectURI.parseNostrConnect(uri) ?: return ConnectResult.InvalidUri
if (offer.relays.isEmpty()) return ConnectResult.NoRelays
if (!signer.isWriteable()) return ConnectResult.NotWriteable
val coordinate = authorizer.coordinateFor(offer.clientPubKey)
val requestedOps = Nip46PermissionAuthorizer.parsePerms(offer.perms)
val firstContact = !ledger.hasPolicy(coordinate)
// First contact: get informed consent — the app's identity, the perms it declared, and a trust
// level — BEFORE we publish the ack or grant anything. A re-pair keeps the existing trust and
// per-op decisions the user may have since changed (e.g. an op set to DENY), so it skips the prompt.
val grantedPolicy: AppSignerPolicy? =
if (firstContact) {
when (val result = Nip46ConsentBridge.requestNostrConnectConsent(coordinate, offer.name, offer.url, offer.image, requestedOps)) {
is AppConnectResult.Connected -> result.policy
AppConnectResult.Blocked, AppConnectResult.Cancelled -> return ConnectResult.Declined
}
} else {
null
}
return try {
// Echo the offer secret back to the client — authored by the transport key so the client
// learns THAT as our remote-signer pubkey (not our identity). Only after consent.
val ack = BunkerResponse(newSubId(), offer.secret, null)
val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner())
client.publish(reply, offer.relays)
if (grantedPolicy != null) {
ledger.setPolicy(coordinate, grantedPolicy)
// The user just reviewed and approved these declared perms, so honor them — including
// sensitive ones — unless they chose PARANOID (ask every time, pre-grant nothing).
if (grantedPolicy != AppSignerPolicy.PARANOID) {
requestedOps.forEach { ledger.setOpDecision(coordinate, it, NostrOpDecision.ALLOW) }
}
}
ledger.updateLastUsed(coordinate)
// Persist the app's label + its relays so it survives a restart, then start listening now.
clientStore.store(
coordinate,
Nip46ClientInfo(name = offer.name, url = offer.url, image = offer.image, relays = offer.relays.map { it.url }.toSet()),
)
refreshExtraRelaysFromStore()
setEnabled(true)
ConnectResult.Connected(offer.clientPubKey, offer.name)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("NIP46Signer") { "nostrconnect pairing failed: ${e.message}" }
ConnectResult.Failed(e.message ?: "unknown error")
}
}
sealed interface ConnectResult {
data class Connected(
val clientPubKey: String,
val name: String?,
) : ConnectResult
data object InvalidUri : ConnectResult
data object NoRelays : ConnectResult
data object NotWriteable : ConnectResult
/** The user reviewed the connect request and declined (cancelled or blocked). */
data object Declined : ConnectResult
data class Failed(
val reason: String,
) : ConnectResult
}
}

View File

@@ -120,12 +120,26 @@ class BlossomServerListState(
hash: HexKey,
size: Long,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, signer, servers)
suspend fun createBlossomMediaAuth(
hash: HexKey,
size: Long,
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createMediaAuth(hash, size, alt, signer, servers)
suspend fun createBlossomDeleteAuth(
hash: HexKey,
alt: String,
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer)
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createDeleteAuth(hash, alt, signer, servers)
suspend fun createBlossomListAuth(
alt: String,
servers: List<String> = emptyList(),
): BlossomAuthorizationEvent = BlossomAuthorizationEvent.createListAuth(signer, alt, servers)
}
/**

View File

@@ -74,7 +74,9 @@ class FeedTopNavFilterState(
) {
fun loadFlowsFor(listName: TopFilter): IFeedFlowsType =
when (listName) {
TopFilter.Global, TopFilter.Selected -> {
// TeleportPicker is a UI-only sentinel (intercepted by the spinner to open the
// map picker); it never reaches here, but fall back to Global for exhaustiveness.
TopFilter.Global, TopFilter.Selected, TopFilter.TeleportPicker -> {
GlobalFeedFlow(followsRelays, proxyRelays, relayFeeds)
}

View File

@@ -41,12 +41,21 @@ private val Context.nappletPermissionsDataStore by preferencesDataStore(name = "
*/
class DataStoreNappletPermissionStore(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletPermissionStore {
constructor(context: Context) : this(context.applicationContext.nappletPermissionsDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletPermissionsDataStore, accountPubKey)
/**
* Grants belong to one account. [accountPubKey] is read at call time, so an account switch moves
* every read and write to that account's namespace with no rebuild — a grant made by one account
* can never authorize another.
*/
private fun scoped(coordinate: String) = "${accountPubKey()}$SEP$coordinate"
override suspend fun load(coordinate: String): Map<NappletCapability, GrantState> {
val prefs = dataStore.data.first()
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
val result = mutableMapOf<NappletCapability, GrantState>()
for ((key, value) in prefs.asMap()) {
val name = key.name
@@ -68,7 +77,7 @@ class DataStoreNappletPermissionStore(
}
override suspend fun clear(coordinate: String) {
val prefix = "$coordinate$SEP"
val prefix = "${scoped(coordinate)}$SEP"
dataStore.edit { prefs ->
val toRemove = prefs.asMap().keys.filter { it.name.startsWith(prefix) }
toRemove.forEach { prefs.remove(it) }
@@ -78,11 +87,15 @@ class DataStoreNappletPermissionStore(
override suspend fun all(): Map<String, Map<NappletCapability, GrantState>> {
val prefs = dataStore.data.first()
val result = mutableMapOf<String, MutableMap<NappletCapability, GrantState>>()
val accountPrefix = "${accountPubKey()}$SEP"
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, "")
// Key is "<account><SEP><coordinate><SEP><CAPABILITY>". Only the active account's grants
// are listed, so the Connected Apps screen never surfaces another account's permissions.
if (!name.startsWith(accountPrefix)) continue
val scoped = name.removePrefix(accountPrefix)
val capName = scoped.substringAfterLast(SEP, "")
val coordinate = scoped.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
@@ -103,7 +116,7 @@ class DataStoreNappletPermissionStore(
private fun keyOf(
coordinate: String,
capability: NappletCapability,
) = stringPreferencesKey("$coordinate$SEP${capability.name}")
) = stringPreferencesKey("${scoped(coordinate)}$SEP${capability.name}")
companion object {
private const val SEP = "\u0000"

View File

@@ -32,14 +32,20 @@ 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.
* DataStore-backed [NappletStorage]. Every key is prefixed with the **active account** and then the
* applet's coordinate, so one napplet's keys can never collide with another's, one account's data is
* never visible to another, and this store is entirely separate from the app's own preferences.
*
* [accountPubKey] is read at call time rather than captured, so switching accounts moves reads and
* writes to the new namespace with no rebuild — an embedded applet always sees the current account's
* data and never the previous one's.
*/
class DataStoreNappletStorage(
private val dataStore: DataStore<Preferences>,
private val accountPubKey: () -> String,
) : NappletStorage {
constructor(context: Context) : this(context.applicationContext.nappletStorageDataStore)
constructor(context: Context, accountPubKey: () -> String) :
this(context.applicationContext.nappletStorageDataStore, accountPubKey)
override suspend fun get(
coordinate: String,
@@ -62,7 +68,9 @@ class DataStoreNappletStorage(
}
override suspend fun keys(coordinate: String): List<String> {
val prefix = "$coordinate "
// Must match keyOf's separator exactly. This filtered on a space while keys are written
// with NUL, so no key could ever match and keys() always returned an empty list.
val prefix = prefixOf(coordinate)
return dataStore.data
.first()
.asMap()
@@ -72,8 +80,11 @@ class DataStoreNappletStorage(
.map { it.removePrefix(prefix) }
}
/** Account first, then applet: isolates accounts from each other, and applets within an account. */
private fun prefixOf(coordinate: String) = "${accountPubKey()}\u0000$coordinate\u0000"
private fun keyOf(
coordinate: String,
key: String,
) = stringPreferencesKey("$coordinate\u0000$key")
) = stringPreferencesKey(prefixOf(coordinate) + key)
}

View File

@@ -34,15 +34,14 @@ import android.os.SystemClock
import android.util.Log
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
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
@@ -50,7 +49,7 @@ 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 com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -78,32 +77,37 @@ import kotlinx.coroutines.launch
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) }
// Persistent grants on disk, session grants in RAM. Shared app-wide (see AppModules) so the
// Connected Apps screens revoke the very grants this broker consults; session grants are dropped
// in onDestroy, which is the "all applet surfaces closed" boundary.
private val ledger get() = Amethyst.instance.nappletPermissionLedger
// 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) }
// Per-applet sandboxed key-value store (namespaced by account + coordinate inside the impl).
private val storage by lazy { DataStoreNappletStorage(applicationContext, Amethyst.instance.nappletAccountScope) }
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() }
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions()
// 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.
// Streams identity.changed to a watching applet. Bound to the surface's LAUNCH account, not the
// app's active one: a surface acts as the account that opened it for its whole life, so switching
// accounts elsewhere is not an identity change *for it*. Announcing the newly-active pubkey here
// would tell a page it had become someone else while its signatures still came back as the
// original — the same desync the launch binding exists to prevent. What this does still report is
// that account going away (logout/removal), which emits "".
private val identityWatch =
NappletIdentityWatch(scope) {
Amethyst.instance.sessionManager.accountContent
.map { (it as? AccountState.LoggedIn)?.account?.signer?.pubKey ?: "" }
NappletIdentityWatch(scope) { boundPubKey ->
Amethyst.instance.accountsCache.accounts
.map { loaded -> if (loaded.containsKey(boundPubKey)) boundPubKey else "" }
}
// Binding is restricted to our own UID by exported=false in the manifest, enforced by the OS.
@@ -114,6 +118,13 @@ class NappletBrokerService : Service() {
override fun onDestroy() {
liveSubscriptions.closeAll()
identityWatch.stop()
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
// the consent dialog promises ("allow for this session") instead of letting it become
// "allow until the app process dies".
dropCachedBroker()
Amethyst.instance.nappletPermissionLedger.endSession()
// Drop any foreground holds this broker still owns so they don't leak past the service.
synchronized(foregroundLeases) {
repeat(foregroundLeases.size) { SandboxForegroundHold.release() }
@@ -241,7 +252,10 @@ class NappletBrokerService : Service() {
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))
// Bind to the account active at mint time: a browser token minted for one account must
// never sign as another if the user switches while the page is still open.
val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true
val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey)
val response =
Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply {
this.data =
@@ -278,17 +292,20 @@ class NappletBrokerService : Service() {
// 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()
// The launch token decides whose key signs — not the active account. A surface opened by
// one account can never be handed another's signer, even while it stays open across a switch.
val broker = brokerFor(session.accountPubKey)
if (broker == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("No account is signed in.")))
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer 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.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start { push(replyTo, it) }
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { 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)
@@ -327,28 +344,44 @@ class NappletBrokerService : Service() {
}
}
/** The launched-as account, or null once it is no longer loaded. */
private fun accountFor(accountPubKey: HexKey): Account? = Amethyst.instance.accountsCache.accounts.value[accountPubKey]
/**
* 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.
* The broker for the account a surface was LAUNCHED as — [NappletLaunchRegistry.Session.accountPubKey],
* never whichever account is active right now.
*
* Resolving live was wrong in a way that defeated per-account isolation: a full-screen host is a
* separate activity that an account switch does not tear down, so its WebView kept account A's
* cookies while requests were signed by B. The page displayed one identity while another signed,
* and B's session was written into A's storage jar — after which even the embedded tab, which is
* rebuilt correctly, showed the wrong account.
*
* Binding to the launch account satisfies both halves of the rule with no extra machinery:
* embedded surfaces are torn down and re-minted on a switch, so they follow the active account,
* while a full-screen surface stays on the account it was opened with.
*
* Returns null when that account is no longer loaded (logged out), so requests fail closed
* rather than silently falling back to someone else's key.
*/
@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
private fun brokerFor(accountPubKey: HexKey): NappletBroker? {
val account = accountFor(accountPubKey) ?: return null
synchronized(brokerLock) {
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
}
}
/**
@@ -403,6 +436,38 @@ class NappletBrokerService : Service() {
}
companion object {
/**
* Guards [cachedBroker]. Both live on the companion rather than the service instance so the
* Connected Apps UI can reach the running broker to revoke its live session grants — the
* screens are plain composables with no binder to this service, and the broker is the only
* holder of the in-memory "allow for this session" signer grants.
*
* Main-process only, like the sibling `Napplet*Registry` objects: the `:napplet` process gets
* its own (unused, empty) copy of these statics and must never touch them.
*/
private val brokerLock = Any()
// The broker for the current account, rebuilt only on account switch (see brokerFor()).
private var cachedBroker: Pair<Account, NappletBroker>? = null
/**
* Drops the live "allow for this session" signer grants the running broker holds for
* [coordinate] (the bare app coordinate). Called when the user revokes or forgets an app in
* Connected Apps: without it the persisted grants are cleared but the in-memory session ones
* keep authorizing signatures until the broker dies, so a revoked app goes on signing.
*
* No-op when no broker has been built yet (no applet has run this process).
*/
suspend fun revokeSessionGrants(coordinate: String) {
val broker = synchronized(brokerLock) { cachedBroker?.second } ?: return
broker.revokeSessionGrants(coordinate)
}
/** Forgets the cached broker, dropping every session grant it holds. */
private fun dropCachedBroker() {
synchronized(brokerLock) { cachedBroker = null }
}
/**
* 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

View File

@@ -23,15 +23,19 @@ 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.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
@@ -41,11 +45,18 @@ 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.draw.clip
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
@@ -54,6 +65,7 @@ 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.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
/**
@@ -168,20 +180,74 @@ private fun NappletConsentDialog(
)
}
// Operation detail box (may include content preview)
if (info.operationSummary.isNotBlank()) {
// Operation detail box (may include content preview), plus the full event behind a
// toggle: the summary truncates content and cannot spell out every tag, so for kinds
// whose payload IS the tags (3, 5, 10000, 10002) this is the only complete disclosure.
if (info.operationSummary.isNotBlank() || info.rawData.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,
)
Column(modifier = Modifier.padding(12.dp)) {
if (info.operationSummary.isNotBlank()) {
SelectionContainer {
Text(
info.operationSummary,
style = MaterialTheme.typography.bodySmall,
)
}
}
// A single-account follow/mute change: show who, so the user recognizes
// the face rather than parsing a name they may not read carefully.
info.subject?.let { subject ->
Spacer(Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
RobohashFallbackAsyncImage(
robot = subject.pubKey,
model = subject.pictureUrl,
contentDescription = subject.name,
modifier = Modifier.size(36.dp).clip(CircleShape),
loadProfilePicture = true,
loadRobohash = true,
)
Spacer(Modifier.width(8.dp))
Text(
subject.name,
style = MaterialTheme.typography.bodyMedium,
)
}
}
if (info.rawData.isNotBlank()) {
var showRawData by remember { mutableStateOf(false) }
if (showRawData) {
Spacer(Modifier.height(8.dp))
Surface(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 }) {
Text(
if (showRawData) {
stringResource(R.string.napplet_consent_hide_event)
} else {
stringResource(R.string.napplet_consent_show_event)
},
style = MaterialTheme.typography.labelSmall,
)
}
}
}
}
}

View File

@@ -36,6 +36,26 @@ data class NappletConsentInfo(
/** Whether a persistent "Always allow" choice may be offered (false for per-use caps like payments). */
val allowAlways: Boolean,
val iconUrl: String? = null,
/**
* The full unsigned event the applet asked us to sign, pretty-printed, shown behind a
* "Show Event" toggle. Blank for requests that sign nothing. [operationSummary] is a lossy
* rendering — it truncates content and cannot spell out every tag — so this is the only place
* the user can see exactly what a signature would cover.
*/
val rawData: String = "",
/**
* The one account a follow/mute change is about, when the change names exactly one. Rendered as
* an avatar + name so the user can recognize *who* at a glance instead of reading a bare count.
* Null for multi-account edits and every other request.
*/
val subject: ConsentSubject? = null,
)
/** A single account a consent dialog is about: enough to draw an avatar and a name. */
data class ConsentSubject(
val pubKey: String,
val name: String,
val pictureUrl: String?,
)
/**

View File

@@ -21,22 +21,35 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
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.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
/**
* 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.
* or a sat amount). Localized via app resources.
*
* Reads [account] only to diff a proposed replaceable list (follows, relays, mutes) against the copy
* already cached there, so the dialog can say what a signature would actually change. It never signs,
* mutates, or exposes account state — the values it reads are the user's own public lists.
*/
class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
identity: NappletIdentity,
@@ -51,16 +64,196 @@ class NappletConsentSummary(
} else {
resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled)
}
val consequence = consequenceFor(request)
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
operationSummary = summaryFor(request),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
rawData = rawEventFor(request),
subject = consequence?.subject,
)
}
/**
* Pretty-prints the unsigned event behind the consent dialog's "Show Event" toggle. Only the
* signing requests carry one; everything else has nothing to disclose.
*/
private fun rawEventFor(request: NappletRequest): String =
when (request) {
is NappletRequest.Publish -> rawEvent(request.kind, request.tags, request.content, null)
is NappletRequest.SignEvent -> rawEvent(request.kind, request.tags, request.content, request.createdAt)
else -> ""
}
private fun rawEvent(
kind: Int,
tags: Array<Array<String>>,
content: String,
createdAt: Long?,
): String =
buildString {
append("kind: ").append(kind).append('\n')
createdAt?.let { append("created_at: ").append(it).append('\n') }
append("tags:")
if (tags.isEmpty()) {
append(" []\n")
} else {
append('\n')
tags.fastForEach { tag -> append(" ").append(tag.joinToString(", ", "[", "]")).append('\n') }
}
append("content: ").append(content.ifEmpty { "(empty)" })
}
/** A consequence line, plus the single account it is about when the change names exactly one. */
private data class Consequence(
val text: String,
val subject: ConsentSubject? = null,
)
/**
* A plain-language warning for the kinds whose payload lives entirely in the tags. Without this
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
when (kind) {
ContactListEvent.KIND ->
diffOf(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
MuteListEvent.KIND ->
diffOf(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
}
/**
* Describes what a proposed replaceable list changes relative to the copy already on the account.
* A bare total ("a list of 12 accounts") hides the dangerous case: the alarming edit is a list
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
private fun subjectOf(pubKey: String): ConsentSubject? {
val user = account.cache.getUserIfExists(pubKey) ?: return null
return ConsentSubject(
pubKey = pubKey,
name = user.toBestDisplayName(),
pictureUrl = user.profilePicture(),
)
}
/** The distinct values of every `[tagName, value, …]` tag. */
private fun valuesOf(
tags: Array<Array<String>>,
tagName: String,
): Set<String> {
val out = mutableSetOf<String>()
tags.fastForEach { if (it.size > 1 && it[0] == tagName) out.add(it[1]) }
return out
}
private fun pluralFor(
resId: Int,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
@@ -91,11 +284,14 @@ class NappletConsentSummary(
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null) {
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay)
context.getString(R.string.napplet_consent_pay_no_amount)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)

View File

@@ -39,15 +39,18 @@ import kotlinx.coroutines.launch
*/
class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: () -> Flow<String>,
private val pubKey: (boundPubKey: String) -> Flow<String>,
) {
private var job: Job? = null
fun start(push: (String) -> Unit) {
fun start(
boundPubKey: String,
push: (String) -> Unit,
) {
stop()
job =
scope.launch {
pubKey()
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }

View File

@@ -22,6 +22,7 @@ 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.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.SecureRandom
@@ -45,6 +46,18 @@ object NappletLaunchRegistry {
data class Session(
val identity: NappletIdentity,
val declared: Set<NappletCapability>,
/**
* The account this surface was launched as. Requests resolve their signer through THIS, not
* through whichever account happens to be active when they arrive.
*
* A full-screen host is a separate activity that an account switch does not tear down, so
* resolving live meant its WebView kept account A's cookies while the broker signed as B —
* a page showing one identity while another signed, and B's session written into A's
* storage jar. Binding here gives both halves of the rule for free: embedded surfaces are
* rebuilt on a switch, so they re-mint and follow the active account, while a full-screen
* surface keeps the account it was opened with.
*/
val accountPubKey: HexKey,
)
// Access-ordered + capped so tokens from long-closed napplets can't accumulate without bound. The
@@ -60,9 +73,10 @@ object NappletLaunchRegistry {
fun register(
identity: NappletIdentity,
declared: Set<NappletCapability>,
accountPubKey: HexKey,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared)
sessions[token] = Session(identity, declared, accountPubKey)
return token
}

View File

@@ -120,7 +120,16 @@ object NappletLauncher {
// 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)
// Bound to the account launching it, so the surface keeps signing as that account even if the
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
// An empty key can never match a loaded account, so a launch with nobody signed in fails
// closed at the broker rather than falling back to whoever signs in later.
val launchAccountPubKey =
Amethyst.instance.sessionManager
.loggedInAccount()
?.pubKey
.orEmpty()
val launchToken = NappletLaunchRegistry.register(identity, declared, launchAccountPubKey)
// 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.
@@ -156,6 +165,9 @@ object NappletLauncher {
putString(NappletHostContract.EXTRA_HOST_PROFILE, profile.name)
putBoolean(NappletHostContract.EXTRA_USE_TOR, useTor)
putString(NappletHostContract.EXTRA_THEME, theme)
// Opaque per-account storage partition, so a napplet/nSite can't carry one npub's cookies
// and localStorage into another. Derived here (the sandbox never sees the pubkey).
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
}

View File

@@ -38,12 +38,13 @@ import java.util.concurrent.atomic.AtomicInteger
* `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).
* The account is supplied per [open] by the caller, which resolves it from the requesting surface's
* LAUNCH account — not from whoever is signed in at the time. A full-screen surface survives an
* account switch, and reading live would have pointed its REQs at the new account's relays while its
* signatures still came from the old one. [open] is reached only after the broker authorized the
* subscription (RELAY consent).
*/
class NappletLiveSubscriptions(
private val account: () -> Account?,
) {
class NappletLiveSubscriptions {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
@@ -62,9 +63,9 @@ class NappletLiveSubscriptions(
fun open(
nappletSubId: String,
filters: List<Filter>,
account: Account?,
push: (String) -> Unit,
) {
val account = account()
val relays = account?.homeRelays?.flow?.value ?: emptySet()
if (account == null || filters.isEmpty() || relays.isEmpty()) {
push(NappletProtocolJson.encodeRelayEose(nappletSubId))

View File

@@ -1,325 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.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))
}
}
}
}
}

View File

@@ -1,94 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.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"
}

View File

@@ -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.Amethyst
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import java.security.MessageDigest
/**
* Mints the opaque per-account WebView storage-profile name the sandbox partitions cookies,
* localStorage, IndexedDB and service workers by.
*
* Every embedded app follows the currently-selected account, and each account gets its OWN storage
* jar: switching from A to B hands B a clean jar, switching back to A restores A's session intact
* (this is a partition, not a wipe).
*
* The name is a hash rather than the pubkey because the `:napplet` sandbox must never learn which
* account it is running for — it only gets a stable, meaningless token. Stability is what makes
* sessions survive a switch, so the derivation must never change once shipped.
*
* The applying half lives in `:nappletHost` (`NappletWebViewProfile`), which validates the shape
* before handing it to `ProfileStore`.
*/
object NappletWebViewProfiles {
/** Domain separator so this hash can never collide with another use of SHA-256(pubkey). */
private const val DOMAIN = "amethyst-webview-profile-v1:"
/** 128 bits of a SHA-256 is far past collision-proof for a handful of on-device accounts. */
private const val NAME_LENGTH = 32
/** The profile name for the account embedded apps currently run as, or null when logged out. */
fun current(): String? = forPubKey(Amethyst.instance.nappletAccountScope())
/** Stable profile name for [pubKey]; null for a blank scope (no account -> shared default jar). */
fun forPubKey(pubKey: HexKey): String? {
if (pubKey.isBlank()) return null
return MessageDigest
.getInstance("SHA-256")
.digest((DOMAIN + pubKey).toByteArray())
.toHexKey()
.take(NAME_LENGTH)
}
}

View File

@@ -23,13 +23,20 @@ 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.connectedApps.signers.NostrSignerOp
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.commons.napplet.signers.NostrSignerOp
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
@@ -38,15 +45,31 @@ fun NostrSignerOp.label(context: Context): String =
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)
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */
/**
* A person's display name for a consent prompt: their profile name when we have it cached, otherwise
* a shortened npub. Never empty — "read your private messages with <nothing>" would be worse than the
* broad wording it replaces.
*/
fun counterpartyLabel(pubKeyHex: HexKey): String {
LocalCache
.getUserIfExists(pubKeyHex)
?.toBestDisplayName()
?.ifBlank { null }
?.let { return it }
val npub = runCatching { NPub.create(pubKeyHex) }.getOrNull()
return if (npub != null) npub.take(12) + "" else pubKeyHex.take(12) + ""
}
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
context: Context,
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): NappletSignerConsentInfo {
): SignerConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
@@ -84,7 +107,13 @@ fun buildSignerConsentInfo(
}
else -> ""
}
return NappletSignerConsentInfo(
val previewTemplate =
when (request) {
is NappletRequest.Publish -> EventTemplate<Event>(TimeUtils.now(), request.kind, request.tags, request.content)
is NappletRequest.SignEvent -> EventTemplate<Event>(request.createdAt, request.kind, request.tags, request.content)
else -> null
}
return SignerConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
op = op,
@@ -92,14 +121,21 @@ fun buildSignerConsentInfo(
contentPreview = preview,
rawData = rawData,
iconUrl = iconUrl,
previewTemplate = previewTemplate,
)
}
/** Creates a [NappletConnectInfo] for the first-connect dialog. */
/**
* Creates a [SignerConnectInfo] for the first-connect dialog. [declared] is the capability set the
* connection pre-grants as ALLOW_ALWAYS on accept, so it is surfaced as
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
* approve a set it never showed them.
*/
fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
): NappletConnectInfo {
declared: Set<NappletCapability> = emptySet(),
): SignerConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
@@ -114,5 +150,18 @@ fun buildConnectInfo(
} else {
identity.identifier.ifBlank { identity.authorPubKey.take(12) + "" }
}
return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl)
// Only the capabilities that actually get pre-granted are listed; SHELL/THEME never prompt and
// VALUE is per-use, so listing them would overstate what accepting hands over.
val preGranted =
declared
.filter { it.requiresConsent && !it.requiresPerUseConsent }
.map { context.getString(it.labelRes()) }
.sorted()
return SignerConnectInfo(
appletTitle = title,
coordinate = identity.coordinate,
domain = domain,
iconUrl = iconUrl,
requestedPermissions = preGranted,
)
}

View File

@@ -27,6 +27,9 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.NappletBroker
import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway
@@ -41,15 +44,12 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt
import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -81,7 +81,9 @@ class AccountNappletGateways(
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
private val signerLedger: NostrSignerPermissionLedger? = null,
) {
private val consentSummary = NappletConsentSummary(context)
// Takes the account so the consent dialog can diff a proposed replaceable list (follows, relays,
// mutes) against the copy already cached here, and say what actually changes.
private val consentSummary = NappletConsentSummary(context, account)
// Reuse the app-wide HTTP client so napplet blob fetches inherit the same Tor
// routing, Onion-Location discovery/rewriting, Blossom cache and pool as the
@@ -138,16 +140,16 @@ class AccountNappletGateways(
}
val connectPrompt =
NostrConnectPrompt { identity ->
NappletConnectCoordinator.requestConnect(
NostrConnectPrompt { identity, declared ->
SignerConnectCoordinator.requestConnect(
context = context,
info = buildConnectInfo(context, identity),
info = buildConnectInfo(context, identity, declared),
)
}
val signerConsent =
NostrSignerConsentPrompt { identity, op, request ->
NappletSignerConsentCoordinator.requestConsent(
SignerConsentCoordinator.requestConsent(
context = context,
info = buildSignerConsentInfo(context, identity, op, request),
)

View File

@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
import com.vitorpamplona.quartz.nip60Cashu.token.CashuToken
import okhttp3.OkHttpClient
import kotlin.coroutines.cancellation.CancellationException
@@ -45,14 +46,23 @@ import kotlin.coroutines.cancellation.CancellationException
* it also picks up NUT-02 per-input fee handling for free.
*/
class MeltProcessor {
/**
* @param knownWalletMints the mint URLs of the user's own NIP-60 wallet. A token
* pointing at one of those was, by definition, issued by a mint the user
* deliberately added, so it is exempt from the private-address block that
* [com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintUrlValidator] applies
* to arbitrary pasted tokens (a self-hosted mint on the LAN is legitimate).
*/
suspend fun melt(
token: CashuToken,
lud16: String,
okHttpClient: (String) -> OkHttpClient,
context: Context,
knownWalletMints: Set<String> = emptySet(),
): MeltResult {
try {
val ops = CashuMintOperations(MintHttpClient(token.mint, okHttpClient))
val isOwnMint = knownWalletMints.any { it.trim().trimEnd('/').equals(token.mint.trim().trimEnd('/'), ignoreCase = true) }
val ops = CashuMintOperations(MintHttpClient(token.mint, userConfigured = isOwnMint, okHttpClient = okHttpClient))
val proofs = token.proofs
// A Lightning address must commit to an amount before we know the
@@ -106,6 +116,14 @@ class MeltProcessor {
} catch (e: Exception) {
if (e is CancellationException) throw e
if (e is LightningAddressResolver.LightningAddressError) throw e
// The mint URL was refused before any request went out: this is OUR
// message, not the mint's, so don't dress it up as "the mint said".
if (e is MintUrlException) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_unsafe_mint_url),
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
)
}
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),

View File

@@ -0,0 +1,302 @@
/*
* 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.service.foreground
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
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.StateFlow
import kotlinx.coroutines.launch
/**
* Shared scaffolding for a foreground service that shields a background job from the
* cached-apps freezer while it runs, rendering a live [NotificationCompat.ProgressStyle]
* progress card driven by a [StateFlow].
*
* This is deliberately NOT a single "do everything" service: Android 14+ binds
* `foregroundServiceType` to the service's actual behavior (and each type carries its
* own permission, budget and start rules), so each workload keeps its own concrete
* subclass with the correct [fgsType]. What's shared here is only the boilerplate —
* channel setup, start/stop-when-idle, the notification skeleton, tap + cancel intents,
* and `onTimeout` — parameterized by a handful of hooks.
*
* Subclasses supply the [fgsType], the [state] flow to watch, [isActive] to decide when
* to stop, [render] to build the card, and [cancelAll] for the cancel action. See
* `PowMiningForegroundService` (shortService) and `BlossomSyncForegroundService`
* (dataSync) for the two consumers.
*/
abstract class FlowProgressForegroundService<T> : Service() {
protected val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var watchJob: Job? = null
/** The Android 14+ `ServiceInfo.FOREGROUND_SERVICE_TYPE_*` this service runs as. */
protected abstract val fgsType: Int
protected abstract val channelId: String
protected abstract val channelNameRes: Int
protected abstract val channelDescRes: Int
protected abstract val notificationId: Int
/** The intent action that routes back here to cancel everything. */
protected abstract val cancelAction: String
protected abstract val cancelLabelRes: Int
protected open val smallIcon: Int = R.drawable.amethyst
/** When non-null, re-render the card on this cadence (for clock-driven text like "time left"). */
protected open val refreshMs: Long? = null
protected abstract fun state(): StateFlow<T>
/** Keep the service (and notification) alive while this is true; stop once it goes false. */
protected abstract fun isActive(value: T): Boolean
protected abstract fun render(value: T): Content
/** Invoked by the cancel action. */
protected abstract fun cancelAll()
/** Called for every emission before [render]; use to update derived subclass state. */
protected open fun onEmission(value: T) {
// No-op by default: only subclasses that keep derived state need this hook.
}
/** Only consulted for the [refreshMs] clock loop; skip re-renders when nothing is moving. */
protected open fun needsClockRefresh(value: T): Boolean = true
/** One-time setup once the watch loop starts (e.g. a benchmark). */
protected open fun onStarted() {
// No-op by default: only subclasses with one-time setup (e.g. a benchmark) override this.
}
/** How to draw the progress bar of the card. */
sealed interface Bar {
data object Indeterminate : Bar
/** A single bar filled to [fraction] in `0f..1f`. */
data class Determinate(
val fraction: Double,
) : Bar
/** [total] equal segments, [done] of them filled — good for "N of M". */
data class Segmented(
val total: Int,
val done: Int,
) : Bar
}
data class Content(
val title: String,
val text: String?,
val bar: Bar,
)
private val tapIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private val cancelIntent: PendingIntent by lazy {
PendingIntent.getService(
this,
1,
Intent(this, this.javaClass).setAction(cancelAction),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Android's contract: every onStartCommand after startForegroundService must call
// startForeground promptly, even on the stop path.
runCatching { startForegroundCompat(state().value) }
.onFailure {
Log.w(logTag(), "startForeground failed; work continues without the service", it)
stopSelf()
return START_NOT_STICKY
}
if (intent?.action == cancelAction) {
cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
watch()
return START_NOT_STICKY
}
/** Foreground-service budget exhausted (shortService ~3 min; dataSync on newer OS). Exit cleanly. */
override fun onTimeout(startId: Int) {
Log.d(logTag()) { "foreground-service budget exhausted; stopping" }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
scope.cancel()
super.onDestroy()
}
private fun watch() {
if (watchJob != null) return
onStarted()
watchJob =
scope.launch {
state().collect { value ->
onEmission(value)
if (!isActive(value)) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
updateNotification(value)
}
}
}
refreshMs?.let { ms ->
scope.launch {
while (true) {
val v = state().value
if (isActive(v) && needsClockRefresh(v)) updateNotification(v)
delay(ms)
}
}
}
}
private fun startForegroundCompat(value: T) {
ensureChannel()
val notification = buildNotification(value)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, fgsType)
} else {
startForeground(notificationId, notification)
}
}
private fun updateNotification(value: T) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(notificationId, buildNotification(value))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
private fun buildNotification(value: T): Notification {
val content = render(value)
val style =
when (val bar = content.bar) {
is Bar.Indeterminate -> NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
is Bar.Determinate ->
if (bar.fraction.isFinite() && bar.fraction in 0.0..1.0) {
NotificationCompat
.ProgressStyle()
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
.setProgress((bar.fraction * 100).toInt())
} else {
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
}
is Bar.Segmented ->
NotificationCompat
.ProgressStyle()
.setProgressSegments(List(bar.total.coerceAtLeast(1)) { NotificationCompat.ProgressStyle.Segment(1) })
.setProgress(bar.done)
}
return NotificationCompat
.Builder(this, channelId)
.setSmallIcon(smallIcon)
.setContentTitle(content.title)
.setContentText(content.text)
.setStyle(style)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, cancelLabelRes), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
}
private fun ensureChannel() {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(channelId) != null) return
manager.createNotificationChannel(
NotificationChannel(channelId, stringRes(this, channelNameRes), NotificationManager.IMPORTANCE_LOW).apply {
description = stringRes(this@FlowProgressForegroundService, channelDescRes)
setShowBadge(false)
},
)
}
protected open fun logTag(): String = this.javaClass.simpleName
companion object {
/**
* Best-effort start of a [FlowProgressForegroundService] subclass. A start from the
* background (e.g. a restore) may be denied — the work then proceeds unprotected and
* the service starts on the next foreground trigger.
*/
fun start(
context: Context,
clazz: Class<out FlowProgressForegroundService<*>>,
tag: String,
) {
try {
context.startForegroundService(Intent(context, clazz))
} catch (e: Exception) {
Log.w(tag, "Could not start foreground service (backgrounded?); work continues unprotected", e)
}
}
}
}

View File

@@ -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.service.location
import android.content.Context
import android.location.Address
import android.location.Geocoder
import android.os.Build
import androidx.annotation.RequiresApi
import com.vitorpamplona.quartz.utils.Log
import java.io.IOException
/**
* Forward geocoding: turn a free-text place query (a city, address, or landmark)
* into a list of candidate [Address]es so the user can jump the map there.
*
* The mirror image of [ReverseGeolocation]: on TIRAMISU+ it uses the async
* listener overload of [Geocoder.getFromLocationName] (the blocking one is
* deprecated there), and falls back to the synchronous call on older devices.
* Both branches funnel through [onReady]; a null result means "no backend, an
* error, or nothing matched" and the caller should degrade gracefully.
*/
@Suppress("DEPRECATION")
class ForwardGeolocation {
companion object {
const val MAX_RESULTS = 5
fun execute(
query: String,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
executeAsync(query, context, onReady)
} else {
onReady(executeSync(query, context))
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
fun executeAsync(
query: String,
context: Context,
onReady: (List<Address>?) -> Unit,
) {
val listener =
object : Geocoder.GeocodeListener {
override fun onGeocode(addresses: List<Address>) {
Log.d("ForwardGeoLocation") { "Found ${addresses.size} addresses for $query" }
onReady(addresses)
}
override fun onError(errorMessage: String?) {
super.onError(errorMessage)
Log.w("ForwardGeoLocation") { "Failure $errorMessage" }
onReady(null)
}
}
Log.d("ForwardGeoLocation") { "Execute Async $query" }
Geocoder(context).getFromLocationName(query, MAX_RESULTS, listener)
}
fun executeSync(
query: String,
context: Context,
): List<Address>? {
Log.d("ForwardGeoLocation") { "Execute Sync $query" }
return try {
Geocoder(context).getFromLocationName(query, MAX_RESULTS)
} catch (e: IOException) {
Log.w("ForwardGeolocation", "IO Error", e)
null
}
}
}
}

View File

@@ -53,8 +53,10 @@ import kotlinx.coroutines.launch
* this is the battery-saver "airplane mode". Persisted, so an explicit off survives
* restarts and crashes.
* - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService],
* "Keep this account active in the background"). While the master is on, the service
* runs as long as **at least one** writable account participates.
* "Keep this account active in the background") **or** its NIP-46 signer toggle
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is
* on, the service runs as long as **at least one** writable account has either flag on — the
* background signer relies on the same foreground service to keep answering requests.
*
* While the master is on, every saved writable account is kept loaded in
* [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
@@ -83,6 +85,11 @@ class AlwaysOnNotificationServiceManager(
* loaded writable account. The service layers run while the master is on AND at
* least one account participates; the master overrides everything when off.
*
* An account "participates" when either its always-on setting **or** its NIP-46
* signer toggle is on: the foreground service (and its restart layers) keep the
* process + shared relay client alive, which is exactly what the background signer
* needs to keep answering requests, so the signer toggle keeps the layers up too.
*
* Idempotent: safe to call again on account switch/login — it restarts the watch.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@@ -105,11 +112,17 @@ class AlwaysOnNotificationServiceManager(
// Master on: keep every writable account loaded so its participation
// flag is observable and its gift wraps can decrypt, then run the
// service only while at least one account is participating.
// service only while at least one account is participating. An account
// participates when its always-on setting OR its NIP-46 signer toggle is
// on — the background signer needs the same foreground service alive.
startMultiAccountPreload()
accountsCache.accounts
.flatMapLatest { accounts ->
val flags = accounts.values.map { it.settings.alwaysOnNotificationService }
val flags =
accounts.values.map { account ->
account.settings.alwaysOnNotificationService
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer }
}
if (flags.isEmpty()) {
flowOf(false)
} else {

View File

@@ -50,11 +50,23 @@ class BootCompletedReceiver : BroadcastReceiver() {
"android.intent.action.QUICKBOOT_POWERON",
Intent.ACTION_MY_PACKAGE_REPLACED,
-> {
Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" }
if (NotificationRelayService.isEnabled(context)) {
Log.d(TAG, "Starting notification relay service")
NotificationRelayService.start(context)
}
// isEnabled() reads plain SharedPreferences (a synchronous disk read, by
// design — see LocalPreferences.globalSettingsPrefs) and scans the accounts
// cache. onReceive runs on the main thread, so hop off it via goAsync() to
// avoid a StrictMode DiskReadViolation while keeping the receiver alive until
// the work finishes.
val pending = goAsync()
Thread {
try {
Log.d(TAG) { "Received ${intent.action}, checking if notification service should start" }
if (NotificationRelayService.isEnabled(context)) {
Log.d(TAG, "Starting notification relay service")
NotificationRelayService.start(context)
}
} finally {
pending.finish()
}
}.start()
}
}
}

View File

@@ -126,9 +126,11 @@ class NotificationRelayService : Service() {
// the service itself once a participant exists.
fun isEnabled(context: Context): Boolean =
try {
// The service also backs the NIP-46 signer, so an account with the signer on
// participates just like one with always-on notifications on.
LocalPreferences.isNotificationServiceEnabled() &&
Amethyst.instance.accountsCache.accounts.value.values.any {
it.settings.alwaysOnNotificationService.value
it.settings.alwaysOnNotificationService.value || it.settings.nip46SignerEnabled.value
}
} catch (e: Exception) {
false

View File

@@ -20,34 +20,17 @@
*/
package com.vitorpamplona.amethyst.service.pow
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWJobState
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.collections.immutable.ImmutableList
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.launch
/**
@@ -56,161 +39,72 @@ import kotlinx.coroutines.launch
* finish mining even after the user backgrounds the app.
*
* Uses the Android 14+ `shortService` type — no special permission, but a
* hard ~3 minute budget. On [onTimeout] the service exits cleanly; every
* hard ~3 minute budget. On `onTimeout` the service exits cleanly; every
* persistable job is already checkpointed by [PowJobStore], so anything still
* unmined resumes on the next app launch. Started on every enqueue (the app
* is necessarily in the foreground then), stops itself when the queue drains.
*
* The notification is a live progress card ([NotificationCompat.ProgressStyle]):
* one track segment per post, filling as jobs complete, indeterminate while a
* single post mines, with a cancel-all action. On Android 16+ it renders as a
* Live Updates chip; older versions fall back to a standard progress bar.
* The notification card (a live [androidx.core.app.NotificationCompat.ProgressStyle]) and all the
* service lifecycle live in [FlowProgressForegroundService]; this subclass only maps mining state
* to that card.
*/
class PowMiningForegroundService : Service() {
private val scope = CoroutineScope(Dispatchers.Main.immediate + SupervisorJob())
private var watchJob: Job? = null
class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<PoWJobState>>() {
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE
override val channelId = CHANNEL_ID
override val channelNameRes = R.string.pow_notification_channel_name
override val channelDescRes = R.string.pow_notification_channel_description
override val notificationId = NOTIFICATION_ID
override val cancelAction = ACTION_CANCEL_ALL
override val cancelLabelRes = R.string.pow_notification_cancel_all
// Session totals so the progress track can show "done / enqueued since the
// service started" — the queue itself only knows what is still pending.
// clock-driven refresh for the time-left text and bar; the shortService budget (~3 min)
// caps this at a handful of updates.
override val refreshMs: Long = PROGRESS_REFRESH_MS
// Session totals so the progress track can show "done / enqueued since the service
// started" — the queue itself only knows what is still pending.
private var sessionTotal = 0
private var lastQueueSize = 0
// Benchmarked once per service run (~250 ms, cached by the estimator);
// read from the notification builder to compute expected durations.
// Benchmarked once per service run (~250 ms, cached by the estimator).
@Volatile
private var hashRate: Double? = null
// Built once per service instance: the intents never change, and
// buildNotification runs on every queue update.
private val tapIntent: PendingIntent by lazy {
PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
},
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private val cancelIntent: PendingIntent by lazy {
PendingIntent.getService(
this,
1,
Intent(this, PowMiningForegroundService::class.java).setAction(ACTION_CANCEL_ALL),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
override fun onCreate() {
super.onCreate()
running = true
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
// Android's contract: every onStartCommand after startForegroundService
// must call startForeground promptly, even on the stop path.
runCatching { startForegroundCompat(currentJobs()) }
.onFailure {
Log.w(TAG, "startForeground failed; mining continues without the service", it)
stopSelf()
return START_NOT_STICKY
}
if (intent?.action == ACTION_CANCEL_ALL) {
Amethyst.instance.powPublishQueue.cancelAll()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
watchQueue()
return START_NOT_STICKY
}
/**
* The shortService budget (~3 min) is exhausted. Exit before the system
* ANRs us: persisted jobs are checkpointed and resume on next launch;
* in-memory jobs keep mining opportunistically until the process freezes.
*/
override fun onTimeout(startId: Int) {
Log.d(TAG) { "shortService budget exhausted; ${currentJobs().size} job(s) left to resume later" }
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onDestroy() {
running = false
scope.cancel()
super.onDestroy()
}
private fun currentJobs(): ImmutableList<PoWJobState> = Amethyst.instance.powPublishQueue.jobs.value
override fun state() = Amethyst.instance.powPublishQueue.jobs
private fun watchQueue() {
if (watchJob != null) return
watchJob =
scope.launch {
Amethyst.instance.powPublishQueue.jobs.collect { jobs ->
if (jobs.size > lastQueueSize) sessionTotal += jobs.size - lastQueueSize
lastQueueSize = jobs.size
override fun isActive(value: ImmutableList<PoWJobState>) = value.isNotEmpty()
if (jobs.isEmpty()) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
} else {
updateNotification(jobs)
}
}
}
override fun cancelAll() = Amethyst.instance.powPublishQueue.cancelAll()
// the estimated-time-left figure and progress fraction only move with
// the clock, not with queue events: benchmark the hash rate once,
// then refresh the card periodically while something is mining.
scope.launch {
hashRate = PoWEstimator.hashesPerSecond()
while (true) {
val jobs = currentJobs()
if (jobs.any { it.isMining }) updateNotification(jobs)
delay(PROGRESS_REFRESH_MS)
}
}
override fun needsClockRefresh(value: ImmutableList<PoWJobState>) = value.any { it.isMining }
override fun onStarted() {
scope.launch { hashRate = PoWEstimator.hashesPerSecond() }
}
private fun startForegroundCompat(jobs: ImmutableList<PoWJobState>) {
ensureChannel(this)
val notification = buildNotification(jobs)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE)
} else {
startForeground(NOTIFICATION_ID, notification)
}
override fun onEmission(value: ImmutableList<PoWJobState>) {
if (value.size > lastQueueSize) sessionTotal += value.size - lastQueueSize
lastQueueSize = value.size
}
private fun updateNotification(jobs: ImmutableList<PoWJobState>) {
val manager = NotificationManagerCompat.from(this)
if (!manager.areNotificationsEnabled()) return
try {
manager.notify(NOTIFICATION_ID, buildNotification(jobs))
} catch (_: SecurityException) {
// POST_NOTIFICATIONS revoked mid-flight; the FGS keeps running.
}
}
override fun render(value: ImmutableList<PoWJobState>): Content {
val done = (sessionTotal - value.size).coerceAtLeast(0)
val total = (done + value.size).coerceAtLeast(1)
private fun buildNotification(jobs: ImmutableList<PoWJobState>): Notification {
val done = (sessionTotal - jobs.size).coerceAtLeast(0)
val total = (done + jobs.size).coerceAtLeast(1)
val current = value.firstOrNull { it.isMining } ?: value.firstOrNull()
val current = jobs.firstOrNull { it.isMining } ?: jobs.firstOrNull()
// expected duration for the job being mined right now, so the card can
// say "≈ 10 minutes left" and fill its bar toward a predictable end.
// expected duration for the job being mined right now, so the card can say
// "≈ 10 minutes left" and fill its bar toward a predictable end.
val rate = hashRate
val startedAt = current?.miningStartedAt
val expectedSec = if (current != null && rate != null) PoWEstimator.estimateSeconds(current.difficulty, rate) else null
@@ -229,44 +123,23 @@ class PowMiningForegroundService : Service() {
val fraction = if (expectedSec != null && elapsedSec != null) elapsedSec / expectedSec else null
val progressStyle: NotificationCompat.ProgressStyle =
if (total <= 1) {
// single post: fill toward the estimated duration; past the
// mean the search is memoryless, so sweep instead of lying.
if (fraction != null && fraction < 1.0) {
NotificationCompat
.ProgressStyle()
.setProgressSegments(listOf(NotificationCompat.ProgressStyle.Segment(100)))
.setProgress((fraction * 100).toInt())
} else {
NotificationCompat.ProgressStyle().setProgressIndeterminate(true)
}
val title =
if (value.size > 1) {
pluralStringRes(this, R.plurals.pow_mining_progress, value.size, value.size)
} else {
NotificationCompat
.ProgressStyle()
.setProgressSegments(List(total) { NotificationCompat.ProgressStyle.Segment(1) })
.setProgress(done)
stringRes(this, R.string.pow_mining_title)
}
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(
if (jobs.size > 1) {
pluralStringRes(this, R.plurals.pow_mining_progress, jobs.size, jobs.size)
} else {
stringRes(this, R.string.pow_mining_title)
},
).setContentText(text)
.setStyle(progressStyle)
.setContentIntent(tapIntent)
.addAction(0, stringRes(this, R.string.pow_notification_cancel_all), cancelIntent)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_PROGRESS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
.build()
val bar =
if (total <= 1) {
// single post: fill toward the estimated duration; past the mean the search is
// memoryless, so sweep instead of lying.
if (fraction != null && fraction < 1.0) Bar.Determinate(fraction) else Bar.Indeterminate
} else {
Bar.Segmented(total, done)
}
return Content(title, text, bar)
}
companion object {
@@ -275,45 +148,19 @@ class PowMiningForegroundService : Service() {
private const val NOTIFICATION_ID = 0x504F57 // "POW"
private const val ACTION_CANCEL_ALL = "com.vitorpamplona.amethyst.pow.CANCEL_ALL"
// clock-driven refresh cadence for the time-left text and bar; the
// shortService budget (~3 min) caps this at a handful of updates.
private const val PROGRESS_REFRESH_MS = 30_000L
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue,
// and each call otherwise round-trips through system_server. A stale
// false only costs one redundant startForegroundService (which Android
// routes to the existing instance's onStartCommand anyway).
// Best-effort de-dup for start(): the queue calls it on EVERY enqueue.
@Volatile
private var running = false
/**
* Best-effort start: enqueue happens while the user is interacting
* with the app, so the foreground-start allowance normally holds. A
* restore during a cold background launch may be denied — mining then
* proceeds unprotected and the service starts on the next enqueue.
* Best-effort start: enqueue happens while the user is interacting with the app,
* so the foreground-start allowance normally holds.
*/
fun start(context: Context) {
if (running) return
try {
context.startForegroundService(Intent(context, PowMiningForegroundService::class.java))
} catch (e: Exception) {
Log.w(TAG, "Could not start mining foreground service (backgrounded?); mining continues unprotected", e)
}
}
private fun ensureChannel(context: Context) {
val manager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.pow_notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = stringRes(context, R.string.pow_notification_channel_description)
setShowBadge(false)
},
)
FlowProgressForegroundService.start(context, PowMiningForegroundService::class.java, TAG)
}
}
}

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
@@ -63,6 +64,27 @@ class RelayProxyClientConnector(
private var lastTorConnection: OkHttpClient? = null
private var lastClearConnection: OkHttpClient? = null
// The network we were last on. The OkHttp clients above are rebuilt off the metered
// bit, so they only tell us about wifi<->cellular; they say nothing about wifi A ->
// wifi B, a VPN coming up, or a captive portal clearing. Those all mint a new
// networkHandle, and after them every existing socket is bound to an interface that
// is gone and every accumulated backoff was earned against a network we have left.
private var lastNetworkId: Long? = null
// The user's Tor preferences. TorRelayEvaluation has no equals() and a fresh instance is
// emitted on unrelated churn, so we track the settings by value. Without this, flipping a
// Tor toggle while Tor is already up leaves both OkHttpClient references identical ->
// transportChanged=false -> a relay parked on a 5-minute backoff keeps waiting it out on
// a transport it is no longer using.
//
// Deliberately only [TorRelaySettings], not the trusted/DM/money relay sets that
// TorRelayEvaluation also carries: those churn constantly while an account's relay lists
// load from the network (observed firing three times during a single cold start). A relay
// moving between classifications can flip its transport too, but forgiving the whole
// pool's backoff every time any relay list updates is far more damage than making that
// one relay serve out its delay.
private var lastTorSettings: TorRelaySettings? = null
@OptIn(FlowPreview::class)
val relayServices =
combine(
@@ -74,51 +96,8 @@ class RelayProxyClientConnector(
) { torSettings, torConnection, clearConnection, connectivity, torStatus ->
RelayServiceInfra(torSettings, torConnection, clearConnection, connectivity, torStatus)
}.debounce(100)
.onEach {
when {
it.connectivity is ConnectivityStatus.StartingService -> {
// ignore
}
it.connectivity is ConnectivityStatus.Off -> {
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${it.connectivity}" }
if (client.isActive()) {
client.disconnect()
}
if (it.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
}
it.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (it.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
client.connect()
}
else -> {
// Only skip the per-relay exponential backoff when the actual HTTP
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
// port not yet listening) honor each relay's backoff so we don't
// reconnect-fail-reconnect on every unrelated infrastructure event.
val transportChanged =
it.torConnection !== lastTorConnection || it.clearConnection !== lastClearConnection
lastTorConnection = it.torConnection
lastClearConnection = it.clearConnection
Log.d("ManageRelayServices") { "Relay Services have changed, reconnecting relays that need to (transportChanged=$transportChanged)" }
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = transportChanged,
)
}
}
}.onStart {
.onEach { apply(it) }
.onStart {
Log.d("ManageRelayServices", "Resuming Relay Services")
client.connect()
}.onCompletion {
@@ -130,4 +109,101 @@ class RelayProxyClientConnector(
SharingStarted.WhileSubscribed(30000),
null,
)
/**
* Decides what a change in the relay infrastructure means for the pool. Split out of the
* flow so the decision table can be exercised directly, without a debounce and a shared
* StateFlow in the way.
*/
fun apply(infra: RelayServiceInfra) {
val networkId = (infra.connectivity as? ConnectivityStatus.Active)?.networkId
val torSettings = infra.evaluator.torSettings
when {
infra.connectivity is ConnectivityStatus.StartingService -> {
// ignore
}
infra.connectivity is ConnectivityStatus.Off -> {
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
if (client.isActive()) {
client.disconnect()
}
if (infra.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity off, Tor idle")
}
// disconnect() already cleared every relay's backoff. Forget the network
// so the next Active is treated as a fresh start rather than a change.
lastNetworkId = null
}
infra.connectivity is ConnectivityStatus.Active && !client.isActive() -> {
Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services")
if (infra.torStatus is TorServiceStatus.Active) {
Log.d("ManageRelayServices", "Connectivity resumed, Tor active")
}
// only calls this if the client is not active. Otherwise goes to the else below
client.connect()
lastNetworkId = networkId
lastTorSettings = torSettings
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
}
else -> {
// Only skip the per-relay exponential backoff when the actual HTTP
// transport changed. Otherwise (e.g. Tor still bootstrapping, the SOCKS
// port not yet listening) honor each relay's backoff so we don't
// reconnect-fail-reconnect on every unrelated infrastructure event.
val transportChanged =
infra.torConnection !== lastTorConnection || infra.clearConnection !== lastClearConnection
// A different network entirely. Every socket is bound to an interface that
// no longer carries traffic, and needsToReconnect() cannot see that (it only
// compares the proxy and the timeouts), so those sockets would otherwise sit
// there until OkHttp's 120s ping finally fails.
val networkChanged = networkId != null && lastNetworkId != null && networkId != lastNetworkId
// Same network and same OkHttp clients, but the user re-classified which
// relays go through Tor. The relays whose transport flipped must re-dial now.
val torPolicyChanged = lastTorSettings != null && torSettings != lastTorSettings
val previousNetworkId = lastNetworkId
lastTorConnection = infra.torConnection
lastClearConnection = infra.clearConnection
lastNetworkId = networkId ?: lastNetworkId
lastTorSettings = torSettings
if (networkChanged) {
Log.d("ManageRelayServices") {
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
}
// Full teardown: disconnect() drops the dead sockets AND clears each
// relay's backoff, so the new network starts from a clean slate.
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
} else {
val freshStart = transportChanged || torPolicyChanged
if (freshStart) {
// The failures behind the current backoffs were measured against a
// transport we are no longer using. ignoreRetryDelays alone only
// skips the gate once and still doubles the stored delay, so a relay
// that fails this one dial would come back worse off than before.
client.resetBackoff()
}
Log.d("ManageRelayServices") {
"Relay Services have changed, reconnecting relays that need to " +
"(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged)"
}
client.reconnect(
onlyIfChanged = true,
ignoreRetryDelays = freshStart,
)
}
}
}
}
}

View File

@@ -0,0 +1,261 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.diagnostics
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
import kotlin.concurrent.thread
/**
* Debug-only cold-start census: what every relay in the pool actually did, and why the ones
* that failed, failed.
*
* The existing instrumentation cannot answer this. [com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStats]
* has counters but no dump path and no failure taxonomy; `RelaySpeedLogger` counts events per
* second but not connection outcomes; `RelayLogger` prints one line per event, which at the
* few-hundred-relay cardinality of the outbox model is thousands of lines to grep rather than
* a table to read.
*
* What this adds: connection outcome bucketed **by cause**, REQ/EOSE/CLOSED accounting per
* relay, and time-to-first-EOSE — so a boot can be read as "N relays served us, M were
* refused for reason R, K were never reachable".
*
* Attach only in debug builds; it holds one small record per relay for the process lifetime.
*/
class BootRelayDiagnostics(
val client: INostrClient,
val dumpAtSeconds: List<Long> = listOf(20, 45, 90),
) {
companion object {
const val TAG = "BootRelayDiag"
/**
* Buckets a connection failure by what actually went wrong. The distinction that
* matters most here is *ours vs theirs*: a SOCKS refusal is our Tor proxy declining
* to open a stream and says nothing about the relay, but it reaches the relay client
* through the same path as a genuine relay failure and is charged to the relay.
*/
fun classify(error: String): String =
when {
error.contains("SOCKS", ignoreCase = true) -> "tor-socks"
error.contains("127.0.0.1") -> "tor-proxy-down"
error.contains("UnknownHostException") -> "dns"
error.contains("SSLHandshakeException") || error.contains("SSLPeerUnverified") -> "tls"
error.contains("SocketTimeoutException") -> "timeout"
error.contains("Server Misconfigured") -> "http-" + (Regex("Response: (\\d+)").find(error)?.groupValues?.get(1) ?: "?")
error.contains("ConnectException") -> "refused"
error.contains("Connection reset") -> "reset"
else -> "other"
}
}
class RelayRecord {
val tentatives = AtomicInteger()
val opens = AtomicInteger()
val disconnects = AtomicInteger()
val reqsSent = AtomicInteger()
val authsSent = AtomicInteger()
val events = AtomicInteger()
val eoses = AtomicInteger()
val notices = AtomicInteger()
/** failure cause -> count, see [classify]. */
val failures = ConcurrentHashMap<String, AtomicInteger>()
/** CLOSED machine-readable prefix (or "unprefixed") -> count. */
val closed = ConcurrentHashMap<String, AtomicInteger>()
val firstOpenAtMs = AtomicLong(0)
val firstEoseAtMs = AtomicLong(0)
fun bump(
map: ConcurrentHashMap<String, AtomicInteger>,
key: String,
) = map.computeIfAbsent(key) { AtomicInteger() }.incrementAndGet()
}
private val records = ConcurrentHashMap<NormalizedRelayUrl, RelayRecord>()
private val startedAtMs = System.currentTimeMillis()
private fun record(url: NormalizedRelayUrl) = records.computeIfAbsent(url) { RelayRecord() }
private fun elapsed() = System.currentTimeMillis() - startedAtMs
private val listener =
object : RelayConnectionListener {
override fun onConnecting(relay: IRelayClient) {
record(relay.url).tentatives.incrementAndGet()
}
override fun onConnected(
relay: IRelayClient,
pingMillis: Int,
compressed: Boolean,
) {
val r = record(relay.url)
r.opens.incrementAndGet()
r.firstOpenAtMs.compareAndSet(0, elapsed())
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
val r = record(relay.url)
r.bump(r.failures, classify(errorMessage))
}
override fun onDisconnected(relay: IRelayClient) {
record(relay.url).disconnects.incrementAndGet()
}
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
val r = record(relay.url)
when (cmd) {
is ReqCmd -> r.reqsSent.incrementAndGet()
is AuthCmd -> r.authsSent.incrementAndGet()
else -> Unit
}
}
override fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
val r = record(relay.url)
when (msg) {
is EventMessage -> r.events.incrementAndGet()
is EoseMessage -> {
r.eoses.incrementAndGet()
r.firstEoseAtMs.compareAndSet(0, elapsed())
}
is NoticeMessage -> r.notices.incrementAndGet()
is ClosedMessage -> r.bump(r.closed, prefixOf(msg.message))
else -> Unit
}
}
}
/** First token of a NIP-01 machine-readable CLOSED/OK message, or "unprefixed". */
private fun prefixOf(message: String?): String {
val text = message?.trim().orEmpty()
if (text.isEmpty()) return "empty"
val head = text.substringBefore(':', "")
return if (head.isNotEmpty() && head.length < 20 && !head.contains(' ')) head else "unprefixed"
}
init {
client.addConnectionListener(listener)
thread(isDaemon = true, name = TAG) {
var last = 0L
dumpAtSeconds.forEach { at ->
Thread.sleep((at - last) * 1000)
last = at
dump(at)
}
}
}
fun detach() = client.removeConnectionListener(listener)
/**
* One line per relay plus a rollup. Kept to a single Log.w per line so the whole census
* survives logcat's per-tag rate limiting on a busy boot.
*/
fun dump(atSeconds: Long) {
val snapshot = records.toMap()
val served = snapshot.filter { it.value.events.get() > 0 }
val opened = snapshot.filter { it.value.opens.get() > 0 }
val neverOpened = snapshot.filter { it.value.opens.get() == 0 }
val causeTotals = mutableMapOf<String, Int>()
val closedTotals = mutableMapOf<String, Int>()
snapshot.values.forEach { r ->
r.failures.forEach { (k, v) -> causeTotals[k] = (causeTotals[k] ?: 0) + v.get() }
r.closed.forEach { (k, v) -> closedTotals[k] = (closedTotals[k] ?: 0) + v.get() }
}
Log.w(TAG, "===== boot census @${atSeconds}s =====")
Log.w(
TAG,
"pool=${snapshot.size} opened=${opened.size} served_events=${served.size} never_opened=${neverOpened.size} " +
"dials=${snapshot.values.sumOf { it.tentatives.get() }} " +
"events=${snapshot.values.sumOf { it.events.get() }} " +
"reqs=${snapshot.values.sumOf { it.reqsSent.get() }} " +
"auths=${snapshot.values.sumOf { it.authsSent.get() }}",
)
Log.w(TAG, "failures_by_cause=" + causeTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
Log.w(TAG, "closed_by_prefix=" + closedTotals.entries.sortedByDescending { it.value }.joinToString { "${it.key}:${it.value}" })
// Relays that cost us dials and gave nothing back, worst first: the wasted-effort list.
Log.w(TAG, "--- top wasted dials (no events received) ---")
snapshot
.filter { it.value.events.get() == 0 }
.entries
.sortedByDescending { it.value.tentatives.get() }
.take(25)
.forEach { (url, r) ->
Log.w(
TAG,
"WASTE ${url.url} dials=${r.tentatives.get()} opens=${r.opens.get()} " +
"fail=[${r.failures.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
"closed=[${r.closed.entries.joinToString { "${it.key}:${it.value.get()}" }}] " +
"reqs=${r.reqsSent.get()} eose=${r.eoses.get()}",
)
}
// The relays actually carrying the boot, so a suppression change can be checked for
// coverage loss rather than just CLOSED reduction.
Log.w(TAG, "--- top event providers ---")
served.entries
.sortedByDescending { it.value.events.get() }
.take(20)
.forEach { (url, r) ->
Log.w(
TAG,
"SERVE ${url.url} events=${r.events.get()} reqs=${r.reqsSent.get()} eose=${r.eoses.get()} " +
"openMs=${r.firstOpenAtMs.get()} eoseMs=${r.firstEoseAtMs.get()} dials=${r.tentatives.get()}",
)
}
Log.w(TAG, "===== end census @${atSeconds}s =====")
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.model.Account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
/**
* Rebuilds a Messages-inbox subscription's filters whenever the user flips [type]'s
* load-toggle in Settings Messages. A disabled type's `updateFilter` returns no filters, so a
* flip to off empties the live subscription and a flip back on re-arms it — no restart needed.
*
* Only the boolean for [type] is watched (via distinct + drop(1)), so unrelated toggle changes
* don't churn this assembler.
*/
fun CoroutineScope.launchChatFeedToggleObserver(
account: Account,
type: ChatFeedType,
onToggle: () -> Unit,
): Job =
launch(Dispatchers.IO) {
account.settings.enabledChatFeeds
.map { type in it }
.distinctUntilChanged()
.drop(1)
.collect { onToggle() }
}

View File

@@ -39,9 +39,13 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.datasource.ConcordChannelHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupThreadFeedFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupJoinedStateFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenChatTailFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupOpenThreadsHistoryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsOnRelayFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
@@ -126,11 +130,18 @@ class RelaySubscriptionsCoordinator(
// assembler above (same as NIP-28 public chats), so only the group-specific surfaces get their
// own here.
val relayGroupsOnRelay = RelayGroupsOnRelayFilterAssembler(client) // browsing one relay's channel list
val relayGroupMyJoinedGroups = RelayGroupMyJoinedGroupsFilterAssembler(client) // metadata+rosters of groups I've joined
val relayGroupThreadFeed = RelayGroupThreadFeedFilterAssembler(client) // a group's forum-threads tab
val relayGroupWarmup = RelayGroupWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupOpenThreads = RelayGroupOpenThreadsFilterAssembler(client) // a group's forum-threads tab (recent tail)
val relayGroupOpenThreadsHistory = RelayGroupOpenThreadsHistoryFilterAssembler(client) // the Threads tab's backward history pager
val relayGroupCardWarmup = RelayGroupCardWarmupFilterAssembler(client) // prefetching a group before it's opened
val relayGroupsDiscovery = RelayGroupsDiscoveryFilterAssembler(client) // the cross-relay Discover feed
// NIP-29 chat, split state-vs-content like the DM / Concord stacks (see
// amethyst/plans/2026-07-18-nip29-group-chat-subscriptions.md).
val relayGroupJoinedState = RelayGroupJoinedStateFilterAssembler(client) // always-on: joined groups' metadata/roster/roles/pins
val relayGroupJoinedChatTail = RelayGroupJoinedChatTailFilterAssembler(client) // always-on: batched #h recent-tail for Messages previews
val relayGroupOpenChatTail = RelayGroupOpenChatTailFilterAssembler(client) // the open group's recent chat (covers non-joined)
val relayGroupOpenChatHistory = RelayGroupOpenChatHistoryFilterAssembler(client) // the open group's on-demand backward history pager
// Concord Channels (encrypted communities). One assembler keeps every joined community's
// control + channel planes live (kind-1059 by derived stream address).
val concordChannels = ConcordChannelFilterAssembler(client)
@@ -201,10 +212,14 @@ class RelaySubscriptionsCoordinator(
val all =
listOf(
relayGroupsOnRelay,
relayGroupMyJoinedGroups,
relayGroupThreadFeed,
relayGroupWarmup,
relayGroupOpenThreads,
relayGroupOpenThreadsHistory,
relayGroupCardWarmup,
relayGroupsDiscovery,
relayGroupJoinedState,
relayGroupJoinedChatTail,
relayGroupOpenChatTail,
relayGroupOpenChatHistory,
concordChannels,
concordChannelHistory,
account,

View File

@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
@@ -56,13 +57,20 @@ class AccountFilterAssembler(
// History: older gift wraps, loaded on demand in bounded one-shot slices.
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
// Live tail: the recent week of notifications from the inbox + group host relays.
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
giftWraps,
giftWrapsHistory,
AccountDraftsEoseManager(client, ::allKeys),
AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys),
notifications,
notificationsHistory,
MarmotGroupEventsEoseManager(client, ::allKeys),
)

View File

@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -51,6 +53,7 @@ class MarmotGroupEventsEoseManager(
): List<RelayBasedFilter> {
val manager = key.account.marmotManager ?: return emptyList()
if (!key.account.isWriteable()) return emptyList()
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.MARMOT)) return emptyList()
val result = mutableListOf<RelayBasedFilter>()
val fallbackRelays = key.account.homeRelays.flow.value
@@ -130,6 +133,7 @@ class MarmotGroupEventsEoseManager(
invalidateFilters()
}
},
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.MARMOT) { invalidateFilters() },
)
return super.newSub(key)

View File

@@ -28,7 +28,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,17 +50,35 @@ class AccountNotificationsEoseFromInboxRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Backward-paging boundary: once the feed has filled a page, ask for everything older than
// its oldest card. It stays null until then — see the note on the missing week floor below,
// which is what let it stay null forever on a quiet inbox.
val pagingBoundary = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
val inbox =
key.account.notificationRelays.flow.value.flatMap {
// No `since` floor on the first fetch. These filters are scoped by `#p` to my own
// key and carry a relay-side `limit`, so an all-time query costs one index scan and
// returns at most `limit` events, newest first — exactly what Home does (it passes
// `since ?: boundary`, i.e. null on a cold start).
//
// This used to fall back to `oneWeekAgo()`, which silently emptied the tab for
// anyone whose last mention was older than a week: EOSE `since` is in-memory only,
// so EVERY cold start re-pinned the window to 7 days, and the paging boundary above
// could never rescue it — it only arms once the feed holds a full page, and the feed
// could not fill because the query only ever asked for a week. A fresh install of an
// established account hit the same deadlock.
val notificationSince = since?.get(it)?.time ?: pagingBoundary
filterSummaryNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
) +
filterNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = since?.get(it)?.time ?: key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo(),
since = notificationSince,
)
}
@@ -76,7 +93,9 @@ class AccountNotificationsEoseFromInboxRelaysManager(
relay = relay,
pubkey = user(key).pubkeyHex,
groupIds = groupIds.distinct(),
since = since?.get(relay)?.time ?: TimeUtils.oneWeekAgo(),
// Same reasoning as the inbox filters above: `#p` + `#h` + `limit = 200`
// already bound this, so a week floor only hides older group activity.
since = since?.get(relay)?.time ?: pagingBoundary,
)
}

View File

@@ -27,7 +27,6 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -51,8 +50,11 @@ class AccountNotificationsEoseFromRandomRelaysManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// only loads this after the feed is built
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled() ?: TimeUtils.oneWeekAgo()
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
// behind it: this probe is `#p`-scoped to me with `limit = 20`, so relays answer with the 20
// newest either way — the floor only ever hid notifications older than a week, and since the
// boundary above needs a full page to arm, a quiet inbox could never page past it.
val defaultSince = key.feedContentStates.notifications.lastNoteCreatedAtIfFilled()
return (key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value).flatMap {
val since = since?.get(it)?.time ?: defaultSince
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)

View File

@@ -0,0 +1,222 @@
/*
* 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.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* Loads the account's notification **history** — everything older than the one-week live tail
* ([AccountNotificationsEoseFromInboxRelaysManager]) — by **`until`+`limit` paging, per relay, on
* demand**, so the notifications feed can be scrolled back in time instead of being pinned to the
* recent week.
*
* There is no proactive walk: each relay advances exactly one page when the feed's on-screen
* window-limit marker for that relay asks ([advance]), then **parks** at its window limit. The markers
* are the drivers — a relay pages only while its marker is visible, and keeps paging as long as it
* stays visible (see the notifications card feed). So a spam-dense relay never floods: the user has to
* scroll through its notifications to pull more, and nothing is fetched while its marker is off screen.
*
* Relays paged: the same set the live inbox loader covers — the user's inbox relays (all notification
* kinds tagging me) plus each joined NIP-29 group's host relay (group-activity kinds scoped by `#h`).
* The foreground "random follows" straggler query ([AccountNotificationsEoseFromRandomRelaysManager],
* tiny latest-N limits) is deliberately live-tail only and is NOT paged here.
*
* The per-relay cursors live on the [Account] (so they share the account's lifetime); this class binds
* the single-active [BackwardRelayPager] orchestrator to them on [newSub], builds the notification REQ
* filters, and forwards relay callbacks into the pager. A relay is *done* once it answers an empty page;
* one that won't answer (auth CLOSE, unreachable, or silent) is flagged *stalled* but kept. [exhausted]
* flips once every relay is either done or stalled.
*/
class AccountNotificationsHistoryEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
// A modest page: each marker-triggered advance pulls ~500 older notifications, digestible to render
// and enough to fill a scroll, rather than the gift-wrap default (a whole encrypted-blob band at once).
private val pager = BackwardRelayPager("notifications.history", pageLimit = 500)
val loadingMore: StateFlow<Boolean> = pager.loadingMore
val status: StateFlow<PagingStatus> = pager.status
// Each joined group's id, bucketed by the normalized host relay it lives on. Used both to route the
// group filter and (its keys) to add group host relays to the paged relay set.
private fun groupsByRelay(account: Account): Map<NormalizedRelayUrl, List<String>> =
account.relayGroupList.liveRelayGroupList.value
.groupBy({ RelayUrlNormalizer.normalizeOrNull(it.relayUrl) }, { it.groupId })
.mapNotNull { (relay, ids) -> relay?.let { it to ids.distinct() } }
.toMap()
// The full relay set this account pages notifications back through: inbox relays + group host relays.
private fun notificationRelaySet(account: Account): Set<NormalizedRelayUrl> = account.notificationRelays.flow.value + groupsByRelay(account).keys
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) return emptyList()
val pubkey = user(key).pubkeyHex
val inbox = key.account.notificationRelays.flow.value
val groups = groupsByRelay(key.account)
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
// re-REQ it — it stays parked until the marker advances it again.
val armed = pager.armedRelays(inbox + groups.keys)
if (armed.isEmpty()) return emptyList()
return armed.flatMap { relay ->
val until = pager.requestedUntilFor(relay) ?: return@flatMap emptyList()
Log.d(TAG) { "[notifications.history] REQ ${relay.url} until=$until limit=${pager.pageLimit}" }
buildList {
if (relay in inbox) {
addAll(filterNotificationsHistoryToPubkey(relay, pubkey, until, pager.pageLimit))
}
groups[relay]?.let { groupIds ->
addAll(filterGroupNotificationsHistoryToPubkey(relay, pubkey, groupIds, until, pager.pageLimit))
}
}
}
}
/** Steps a single [relay] to its next, older page. Driven by that relay's on-screen window-limit marker. */
fun advance(relay: NormalizedRelayUrl) {
if (pager.advance(relay)) invalidateFilters()
}
/** Steps every not-done, not-in-flight relay one page. For the empty/initial boundary (nothing to scroll). */
fun advanceAll() {
if (pager.advanceAll()) {
Log.d(TAG) { "[notifications.history] advanceAll (empty-feed bootstrap)" }
invalidateFilters()
}
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
// Repoint the single-active orchestrator at this account's notification cursors and the relay set
// it fans out to, refreshing the display flows from the restored progress.
pager.bind(key.account.notificationHistory, key.account.scope) { notificationRelaySet(key.account) }
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
// A relay joining/leaving the paged set (inbox change, group join/leave) re-issues the REQ
// so a newly-added relay can be armed and a removed one drops out.
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow
.sample(1000)
.collectLatest { invalidateFilters() }
},
key.account.scope.launch(Dispatchers.IO) {
key.account.relayGroupList.liveRelayGroupList
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return requestNewSubscription(historyListener(key))
}
private fun historyListener(key: AccountQueryState): SubscriptionListener {
// A just-backgrounded account's subscription can still deliver after the orchestrator rebinds to
// another account; gate the pager (single-active) on whether it's still bound to THIS account's
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onEvent(relay, event.createdAt)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors) && pager.onEose(relay)) {
Log.d(TAG) { "[notifications.history] ${relay.url} reached the bottom (done)" }
}
// No auto-advance: the relay parks here until its marker asks for the next page.
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onClosed(relay, message)
}
override fun onCannotConnect(
relay: NormalizedRelayUrl,
message: String,
forFilters: List<Filter>?,
) {
if (pager.isBoundTo(myCursors)) pager.onCannotConnect(relay, message)
}
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
private const val TAG = "NotificationPagination"
}
}

View File

@@ -124,6 +124,70 @@ val NotificationsPerKeyKinds3 =
AttestorRecommendationEvent.KIND,
)
/**
* Every kind the notifications feed cares about on the user's inbox relays, flattened into one list.
* The live-tail query ([filterNotificationsToPubkey] / [filterSummaryNotificationsToPubkey]) splits these
* across several filters with different per-kind limits; backward history paging instead asks ONE filter
* per relay so the single until+limit cursor stays gap-proof (an empty page truly means "nothing older").
*/
val AllNotificationKinds =
(SummaryKinds + NotificationsPerKeyKinds + NotificationsPerKeyKinds2 + NotificationsPerKeyKinds3).distinct()
/**
* One backward-paging page of notifications on an inbox relay: the N newest events tagging me
* ([AllNotificationKinds], `#p` = me) strictly older than [until]. A single filter (not the live query's
* split) so the [BackwardRelayPager][com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager]
* cursor tracking the oldest delivered `created_at` can't skip a band that a per-kind sub-limit capped.
*/
fun filterNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = AllNotificationKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = limit,
until = until,
),
),
)
}
/**
* One backward-paging page of NIP-29 group-activity notifications on a group's host relay:
* [GroupNotificationKinds] tagging me (`#p`) inside my joined groups (`#h`), strictly older than [until].
*/
fun filterGroupNotificationsHistoryToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
groupIds: List<String>,
until: Long,
limit: Int,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty() || groupIds.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = limit,
until = until,
),
),
)
}
fun filterSummaryNotificationsToPubkey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.privateChats.DmHistoryTuning
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.commons.relayClient.paging.WindowLoadTracker
@@ -27,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.paging.trackingListener
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.DmRelayLog
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -63,7 +65,7 @@ class AccountGiftWrapsEoseManager(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) {
if (!key.account.isWriteable() || !key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) {
windowLoad.setExpectedRelays(emptySet())
return emptyList()
}
@@ -90,6 +92,7 @@ class AccountGiftWrapsEoseManager(
key.account.dmRelays.flow
.collectLatest { invalidateFilters() }
},
key.account.scope.launchChatFeedToggleObserver(key.account, ChatFeedType.NIP17) { invalidateFilters() },
)
return requestNewSubscription(

View File

@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.relayClient.nip17Dm.filterGiftWrapsToPubkey
import com.vitorpamplona.amethyst.commons.relayClient.paging.BackwardRelayPager
import com.vitorpamplona.amethyst.commons.relayClient.paging.PagingStatus
@@ -73,6 +74,7 @@ class AccountGiftWrapsHistoryEoseManager(
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
if (!key.account.isWriteable()) return emptyList()
if (!key.account.settings.isChatFeedEnabled(ChatFeedType.NIP17)) return emptyList()
// Only relays that have been advanced (armed) and aren't done carry a REQ. A relay that finished a
// page keeps the same `until` here, so re-assembly (triggered when ANOTHER relay advances) doesn't
// re-REQ it — it stays parked until the UI advances it again.

View File

@@ -51,19 +51,31 @@ class ForegroundTracker : Application.ActivityLifecycleCallbacks {
_isForeground.value = startedActivities > 0
}
// Only started/stopped drive the foreground signal; the remaining lifecycle
// callbacks are required by the interface but irrelevant to this tracker.
override fun onActivityCreated(
activity: Activity,
savedInstanceState: Bundle?,
) {}
) {
// No-op: creation does not change foreground state.
}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityResumed(activity: Activity) {
// No-op: foreground is derived from started/stopped counts, not resume.
}
override fun onActivityPaused(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {
// No-op: foreground is derived from started/stopped counts, not pause.
}
override fun onActivitySaveInstanceState(
activity: Activity,
outState: Bundle,
) {}
) {
// No-op: state saving does not change foreground state.
}
override fun onActivityDestroyed(activity: Activity) {}
override fun onActivityDestroyed(activity: Activity) {
// No-op: onActivityStopped already decremented the counter.
}
}

View File

@@ -46,7 +46,7 @@ class ResourceUsageReportAssembler {
sb.append("\n\n")
sb.append("| Prop | Value |\n")
sb.append("| --- | --- |\n")
sb.append(TABLE_SEPARATOR)
sb.append("| Manuf | ${Build.MANUFACTURER} |\n")
sb.append("| Model | ${Build.MODEL} |\n")
sb.append("| Android | ${Build.VERSION.RELEASE} |\n")
@@ -64,7 +64,7 @@ class ResourceUsageReportAssembler {
if (memory != null) {
sb.append("\n**Memory right now**\n\n")
sb.append("| Metric | Value |\n")
sb.append("| --- | --- |\n")
sb.append(TABLE_SEPARATOR)
sb.append("| Device class | ${memory.memoryClassMb} MB |\n")
sb.append("| App heap | ${memory.heapUsedMb} / ${memory.heapMaxMb} MB |\n")
sb.append("| Native heap | ${memory.nativeHeapUsedMb} MB |\n")
@@ -88,7 +88,7 @@ class ResourceUsageReportAssembler {
private fun summaryTable(s: UsageSummary): String =
buildString {
append("| Metric | Value |\n")
append("| --- | --- |\n")
append(TABLE_SEPARATOR)
append("| Cellular data (background) | ${formatBytes(s.mobileBytesBg)} |\n")
append("| Cellular data (foreground) | ${formatBytes(s.mobileBytesFg)} |\n")
append("| Wi-Fi data | ${formatBytes(s.wifiBytesBg + s.wifiBytesFg)} |\n")
@@ -129,6 +129,9 @@ class ResourceUsageReportAssembler {
}
companion object {
/** Markdown table header/body separator row. */
private const val TABLE_SEPARATOR = "| --- | --- |\n"
fun formatBytes(bytes: Long): String =
when {
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))

View File

@@ -24,6 +24,8 @@ import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -34,10 +36,13 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -200,6 +205,9 @@ class UploadOrchestrator {
context: Context,
): UploadingFinalState {
updateState(0.2, UploadingState.Uploading)
// BUD-05: route through /media (optimize) when the user opted in. The forced-signer
// path (e.g. NIP-46 draft signing) always uses the bit-exact /upload.
val useMedia = forcedSigner == null && account.settings.optimizeMediaOnUpload.value
return try {
val result =
BlossomUploader()
@@ -211,27 +219,93 @@ class UploadOrchestrator {
sensitiveContent = contentWarningReason,
serverBaseUrl = serverBaseUrl,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
// Use a t=media token when optimizing via /media, otherwise a plain
// t=upload token. Tokens are intentionally NOT server-scoped on the
// upload path: some servers reject an upload whose auth carries a
// `server` tag, and upload-token replay is not the threat scoping
// guards against (delete tokens are — those stay scoped).
httpAuth =
if (forcedSigner != null) {
{ hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
} else {
account::createBlossomUploadAuth
when {
forcedSigner != null -> { hash, size, alt -> BlossomAuthorizationEvent.createUploadAuth(hash, size, alt, forcedSigner) }
useMedia -> { hash, size, alt -> account.createBlossomMediaAuth(hash, size, alt) }
else -> { hash, size, alt -> account.createBlossomUploadAuth(hash, size, alt) }
},
context = context,
useMediaEndpoint = useMedia,
)
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
val finalState =
verifyHeader(
uploadResult = result,
localContentType = contentType,
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
originalHash = originalHash,
originalContentType = contentTypeForResult,
)
// BUD-04: replicate the blob to the user's other Blossom servers for redundancy.
// Fire-and-forget on the account scope AFTER the upload is finished: mirroring is
// pure background redundancy, so it must never delay, alter, or fail the upload the
// user already completed, and must not touch the on-screen progress state.
if (finalState is UploadingState.Finished && forcedSigner == null && account.settings.mirrorUploadsToAllServers.value) {
account.scope.launch(Dispatchers.IO) {
try {
mirrorToOtherServers(result, serverBaseUrl, account)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Background mirror failed", e)
}
}
}
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
}
}
/**
* BUD-04 mirror fan-out: asks every *other* Blossom server in the account's
* kind-10063 list to pull the freshly-uploaded blob from [result]'s URL. Runs
* in the background after the upload is finished (see caller), so it never
* delays the user's post or touches the upload progress UI; per-server failures
* are swallowed. Requires the blob's sha256 so server B can verify the download.
*/
private suspend fun mirrorToOtherServers(
result: MediaUploadResult,
primaryServerBaseUrl: String,
account: Account,
) {
val sourceUrl = result.url ?: return
val hash = result.sha256 ?: sourceUrl.substringAfterLast('/').substringBefore('.')
if (hash.length != 64) return
// Only the user's *explicitly configured* kind-10063 servers (flow), NOT the
// DEFAULT_MEDIA_SERVERS fallback that hostNameFlow injects — we must never fan
// uploads out to public defaults the user never opted into.
val primaryDomain = BlossomServerUrl.domain(primaryServerBaseUrl)
val targets =
account.blossomServers.flow.value
.filter { BlossomServerUrl.domain(it) != primaryDomain }
.distinct()
if (targets.isEmpty()) return
targets.forEach { target ->
try {
val auth = account.createBlossomUploadAuth(hash, result.size ?: 0L, "Mirror $hash").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(sourceUrl, target, auth)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("UploadOrchestrator", "Failed to mirror $hash to $target", e)
}
}
}

View File

@@ -0,0 +1,155 @@
/*
* 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.service.uploads.blossom
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
/** Aggregate progress of a running "sync all my blobs to all my servers" sweep. */
@Immutable
data class BlossomSyncState(
val total: Int,
val done: Int,
val failed: Int,
val running: Boolean,
) {
val succeeded get() = done - failed
val fraction get() = if (total == 0) 0f else done.toFloat() / total
}
/** One completed mirror step, streamed so an open manager screen can flip its pill live. */
@Immutable
data class BlossomMirrorResult(
val hash: HexKey,
val server: String,
val ok: Boolean,
)
/**
* App-level BUD-04 mirror queue, modeled on the PoW publish queue: it runs on the
* application IO scope (via [Amethyst.instance]) so a sweep keeps going while the
* user navigates the app, and exposes [state] for a floating progress banner mounted
* at the navigation root. Servers that require payment are skipped (counted as
* failed) — those are paid for individually from the manager screen.
*/
class BlossomMirrorQueue(
private val scope: CoroutineScope,
/** Invoked (on the foreground thread that called [start]) when a sweep begins, to start the FGS. */
private val onActive: () -> Unit = {},
) {
data class Task(
val hash: HexKey,
val sourceUrl: String,
val size: Long?,
val targets: List<String>,
)
private val _state = MutableStateFlow<BlossomSyncState?>(null)
val state: StateFlow<BlossomSyncState?> = _state.asStateFlow()
private val _results = MutableSharedFlow<BlossomMirrorResult>(extraBufferCapacity = 128)
val results: SharedFlow<BlossomMirrorResult> = _results.asSharedFlow()
private var job: Job? = null
val isRunning get() = _state.value?.running == true
/** Enqueue a sweep. No-op if one is already running or there's nothing to do. */
fun start(
account: Account,
tasks: List<Task>,
) {
if (isRunning) return
val work = tasks.flatMap { t -> t.targets.map { t to it } }
if (work.isEmpty()) return
// Publish the active state and start the foreground service synchronously (we're on the
// foreground thread here, which is what dataSync FGS starts require) before the sweep runs.
_state.value = BlossomSyncState(total = work.size, done = 0, failed = 0, running = true)
onActive()
// Parallelize ACROSS servers but stay serial WITHIN a server, so every server gets a
// steady one-at-a-time stream (no hammering / rate-limit storms) while all of the user's
// servers work at once. Wall-clock ≈ the slowest single server's queue, not the sum.
val byServer: Map<String, List<Task>> = work.groupBy({ it.second }, { it.first })
job =
scope.launch {
byServer
.map { (target, serverTasks) ->
async {
for (task in serverTasks) {
val ok = mirrorOne(account, task, target)
_results.tryEmit(BlossomMirrorResult(task.hash, target, ok))
_state.update { it?.copy(done = it.done + 1, failed = it.failed + if (ok) 0 else 1) }
}
}
}.awaitAll()
_state.update { it?.copy(running = false) }
}
}
private suspend fun mirrorOne(
account: Account,
task: Task,
target: String,
): Boolean =
try {
val auth = account.createBlossomUploadAuth(task.hash, task.size ?: 0L, "Mirror ${task.hash}").toAuthorizationHeader()
BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(target))
.mirror(task.sourceUrl, target, auth)
true
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomMirrorQueue", "mirror ${task.hash} -> $target failed", e)
false
}
/** Cancel a running sweep and clear the banner. */
fun cancel() {
job?.cancel()
job = null
_state.value = null
}
/** Dismiss the finished-summary banner (no effect while still running). */
fun dismiss() {
if (!isRunning) _state.value = null
}
}

View File

@@ -0,0 +1,197 @@
/*
* 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.service.uploads.blossom
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
/**
* Settles a BUD-07 [BlossomPaymentRequired] challenge so a blocked upload/mirror can
* be retried. Reuses the account's existing NIP-47 (Nostr Wallet Connect) lightning
* path — this handler never touches keys or moves funds itself, it only asks the
* user's connected wallet to pay the invoice and returns the resulting preimage as
* the [BlossomPaymentProof].
*
* Cashu-only servers (`X-Cashu`, no `X-Lightning`) are not yet supported here; the
* caller should surface that a lightning wallet is required.
*/
object BlossomPaymentHandler {
/**
* Hard ceiling on a single BUD-07 charge, in sats.
*
* BUD-07 charges are per-blob storage fees: real paid Blossom servers ask
* single-digit to low-hundreds of sats for a media upload. 10,000 sats is
* roughly USD 10 at a 100k BTC — one to two orders of magnitude above any
* legitimate per-blob fee, so it never gets in a real user's way, while
* capping what a hostile or compromised server can drain in one prompt.
* Anything above this is refused outright rather than shown to the user,
* because the value is server-chosen and a user tapping through a dialog is
* not a meaningful defence against a four-digit-sat surprise.
*/
const val MAX_PAYMENT_SATS = 10_000L
/** Outcome of [pay]. Everything except [Paid] means no proof and no retry. */
sealed interface PayResult {
data class Paid(
val proof: BlossomPaymentProof,
) : PayResult
/** The amount failed [checkAmount]; [reason] is user-facing. */
data class Refused(
val reason: String,
) : PayResult
/** No invoice, no wallet, or the wallet request could not be sent. */
data object Unavailable : PayResult
/** The wallet never answered. The invoice stays blocked — see [InFlightInvoices]. */
data object TimedOut : PayResult
}
/** Verdict on the invoice amount, before any money moves. */
sealed interface AmountCheck {
data class Ok(
val sats: Long,
) : AmountCheck
/** BOLT-11 with no amount: the payee picks it. Never payable unattended. */
data object Amountless : AmountCheck
data class OverCap(
val sats: Long,
) : AmountCheck
/** The invoice asks for something other than what the dialog told the user. */
data class Mismatch(
val shownSats: Long?,
val actualSats: Long,
) : AmountCheck
}
/**
* Re-derives the amount from the invoice itself and checks it against both the
* cap and [shownSats] — the number the confirmation dialog put in front of the
* user. The amount shown must be the amount paid, so a server that swapped the
* invoice (or leaned on a misleading `X-Reason`) cannot get a different sum
* approved than the one the user agreed to.
*/
fun checkAmount(
payment: BlossomPaymentRequired,
shownSats: Long?,
): AmountCheck {
val actual = amountSats(payment) ?: return AmountCheck.Amountless
if (actual > MAX_PAYMENT_SATS) return AmountCheck.OverCap(actual)
if (shownSats != actual) return AmountCheck.Mismatch(shownSats, actual)
return AmountCheck.Ok(actual)
}
/** Human-readable refusal text for a non-[AmountCheck.Ok] verdict. */
fun refusalReason(check: AmountCheck): String =
when (check) {
is AmountCheck.Ok -> ""
is AmountCheck.Amountless -> "The server's invoice does not state an amount. Amethyst will not pay it."
is AmountCheck.OverCap -> "The server asked for ${check.sats} sats, above the $MAX_PAYMENT_SATS sat limit for a media-server payment. Nothing was paid."
is AmountCheck.Mismatch ->
"The server's invoice is for ${check.actualSats} sats, not the ${check.shownSats ?: 0} sats shown. Nothing was paid."
}
/** True when this account has a wallet we can pay the lightning invoice with. */
fun canPay(
account: Account,
payment: BlossomPaymentRequired,
): Boolean = payment.lightning != null && account.nip47SignerState.hasWalletConnectSetup()
/**
* The invoice amount in sats for display in a confirmation prompt, or null when it is absent or
* unreadable. `getAmountInSats` returns ZERO for an amountless BOLT11 rather than null, so a bare
* read renders "Pay 0 sats" — telling the user a payment is free when the amount is actually
* unspecified and chosen by the payee.
*/
fun amountSats(payment: BlossomPaymentRequired): Long? =
payment.lightning?.let {
runCatching { LnInvoiceUtil.getAmountInSats(it).toLong() }.getOrNull()?.takeIf { sats -> sats > 0 }
}
/**
* Pays the challenge's BOLT-11 invoice via NWC and returns the proof.
*
* [shownSats] is what the confirmation dialog displayed; the invoice is
* re-read here and must match it and sit under [MAX_PAYMENT_SATS], otherwise
* nothing is sent to the wallet at all.
*/
suspend fun pay(
account: Account,
payment: BlossomPaymentRequired,
shownSats: Long?,
): PayResult {
val invoice = payment.lightning ?: return PayResult.Unavailable
if (!account.nip47SignerState.hasWalletConnectSetup()) return PayResult.Unavailable
val check = checkAmount(payment, shownSats)
if (check !is AmountCheck.Ok) {
Log.w("BlossomPayment", "refusing invoice: ${refusalReason(check)}")
return PayResult.Refused(refusalReason(check))
}
// Never send the same invoice twice: an earlier attempt may still settle.
if (!InFlightInvoices.tryClaim(invoice)) {
return PayResult.Refused(
"A payment for this invoice was already sent to your wallet and never confirmed. Amethyst will not pay it again.",
)
}
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}
} catch (e: Exception) {
Log.w("BlossomPayment", "Failed to send NWC payment request", e)
// The request never left, so the invoice is definitively not in flight.
InFlightInvoices.release(invoice)
return PayResult.Unavailable
}
// NIP-47 offers no cancel for an outstanding pay_invoice, so a timeout
// cannot stop the payment — it can only stop us from sending it again.
// Deliberately do NOT release the claim on the timeout path.
val answered = withTimeoutOrNull(PAYMENT_TIMEOUT_MS) { preimageResult.await() }
if (answered == null && !preimageResult.isCompleted) return PayResult.TimedOut
InFlightInvoices.release(invoice)
val preimage = answered ?: return PayResult.Unavailable
return PayResult.Paid(BlossomPaymentProof(lightningPreimage = preimage))
}
/**
* How long we wait for the wallet. Matches the previous behaviour; note the
* NIP-47 filter itself is dropped after 60s, so a reply past 90s cannot reach
* us anyway — which is exactly why the invoice stays blocked afterwards.
*/
private const val PAYMENT_TIMEOUT_MS = 90_000L
}

View File

@@ -0,0 +1,84 @@
/*
* 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.service.uploads.blossom
import android.content.Context
import android.content.pm.ServiceInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.service.foreground.FlowProgressForegroundService
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Foreground service that keeps the BUD-04 "sync all" sweep ([BlossomMirrorQueue]) running
* while the app is backgrounded. Uses the Android 14+ `dataSync` type — the correct type for
* an upload/download/sync operation (a multi-file mirror routinely exceeds the `shortService`
* ~3-minute budget that PoW mining uses).
*
* `dataSync` must be started while the app is foreground; "Sync all" is user-initiated from the
* manager, so that holds. All of the notification + lifecycle lives in the shared
* [FlowProgressForegroundService]; this maps the sweep's [BlossomSyncState] onto the card.
*/
class BlossomSyncForegroundService : FlowProgressForegroundService<BlossomSyncState?>() {
override val fgsType: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
override val channelId = CHANNEL_ID
override val channelNameRes = R.string.blossom_sync_channel_name
override val channelDescRes = R.string.blossom_sync_channel_description
override val notificationId = NOTIFICATION_ID
override val cancelAction = ACTION_CANCEL
override val cancelLabelRes = R.string.blossom_sync_cancel
override fun state() = Amethyst.instance.blossomMirrorQueue.state
override fun isActive(value: BlossomSyncState?) = value?.running == true
override fun cancelAll() = Amethyst.instance.blossomMirrorQueue.cancel()
// State emits on every mirror step (including currentHost changes), so no clock refresh.
override val refreshMs: Long? = null
override fun render(value: BlossomSyncState?): Content {
if (value == null) return Content(stringRes(this, R.string.blossom_syncing), null, Bar.Indeterminate)
val text =
buildString {
append("${value.done} / ${value.total}")
if (value.failed > 0) append(" · ${value.failed} failed")
}
return Content(stringRes(this, R.string.blossom_syncing), text, Bar.Determinate(value.fraction.toDouble()))
}
companion object {
private const val TAG = "BlossomSyncFgs"
private const val CHANNEL_ID = "blossom_sync"
private const val NOTIFICATION_ID = 0x424C4F // "BLO"
private const val ACTION_CANCEL = "com.vitorpamplona.amethyst.blossom.SYNC_CANCEL"
/**
* Started when a sweep begins (from the foreground); stops itself when it finishes.
* No `running` de-dup: a sweep starts this exactly once (via [BlossomMirrorQueue.onActive]),
* and a redundant start would just route to a cheap onStartCommand — whereas a stale
* "already running" flag could leave a fresh sweep with no foreground protection.
*/
fun start(context: Context) {
FlowProgressForegroundService.start(context, BlossomSyncForegroundService::class.java, TAG)
}
}
}

View File

@@ -26,6 +26,7 @@ import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
@@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.RandomInstance
@@ -79,6 +81,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -115,6 +118,7 @@ class BlossomUploader {
okHttpClient,
httpAuth,
context,
useMediaEndpoint,
onProgress,
)
}.mergeLocalMetadata(localMetadata)
@@ -132,6 +136,7 @@ class BlossomUploader {
okHttpClient: (String) -> OkHttpClient,
httpAuth: suspend (hash: HexKey, size: Long, alt: String) -> BlossomAuthorizationEvent?,
context: Context,
useMediaEndpoint: Boolean = false,
onProgress: ((bytesWritten: Long, totalBytes: Long) -> Unit)? = null,
): MediaUploadResult {
checkNotInMainThread()
@@ -142,7 +147,8 @@ class BlossomUploader {
MimeTypeMap.getSingleton().getExtensionFromMimeType(it) ?: extensionFromMimeType(it)
} ?: ""
val apiUrl = BlossomServerUrl.upload(serverBaseUrl)
// BUD-05: /media asks the server to optimize; /upload stores the exact bytes.
val apiUrl = if (useMediaEndpoint) BlossomServerUrl.media(serverBaseUrl) else BlossomServerUrl.upload(serverBaseUrl)
val client = okHttpClient(apiUrl)
val requestBuilder = Request.Builder()
@@ -200,6 +206,10 @@ class BlossomUploader {
response.body.use { body ->
convertToMediaResult(parseResults(body.string()))
}
} else if (response.code == 402) {
// BUD-07: paid server. Surface the payment challenge so the caller can
// tell the user (auto-settlement via the NIP-60 wallet is a follow-up).
throw BlossomPaymentException(serverBaseUrl, BlossomPaymentRequired.fromHeaders { response.headers[it] })
} else {
val errorMessage = response.headers.get(BlossomServerUrl.REASON_HEADER)

View File

@@ -0,0 +1,60 @@
/*
* 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.service.uploads.blossom
/**
* BOLT-11 invoices handed to the NIP-47 wallet whose fate we never learned.
*
* [BlossomPaymentHandler.pay] waits a bounded time for the wallet to reply, but
* NIP-47 has no "cancel this pay_invoice": the request is fire-and-forget, so
* giving up on the wait does **not** stop the payment. An invoice that settles a
* second after we time out still moved the user's money — and if we then let the
* user (or an automatic retry) send the very same invoice again, they pay twice.
*
* So: claim the invoice before sending it, and release the claim only when the
* wallet gives a definitive answer. A timed-out invoice stays claimed for the
* life of the process and can never be paid a second time from this app.
*
* This is the weaker half of the fix — a genuine cancel would be better, but the
* NIP-47 client offers none, so we settle for "never silently pay it twice".
*/
object InFlightInvoices {
private val claimed = mutableSetOf<String>()
/**
* Claims [invoice] for one payment attempt. Returns false when it was already
* claimed and never resolved — the caller must not send it again.
*/
fun tryClaim(invoice: String): Boolean = synchronized(claimed) { claimed.add(invoice) }
/** The wallet gave a definitive answer (paid or explicitly failed): the claim can go. */
fun release(invoice: String) {
synchronized(claimed) { claimed.remove(invoice) }
}
/** True when [invoice] was sent to the wallet and never resolved. */
fun isAwaiting(invoice: String): Boolean = synchronized(claimed) { invoice in claimed }
/** Test-only reset. */
internal fun clear() {
synchronized(claimed) { claimed.clear() }
}
}

View File

@@ -0,0 +1,52 @@
/*
* 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.service.uploads.blossom
/**
* Bounds how often one user action may raise a BUD-07 payment prompt.
*
* The mirror flow retries a target after paying it, and that retry catches every
* exception — including a second `BlossomPaymentException`. Left unbounded, a
* server that pockets the preimage and answers 402 again drives an indefinite
* pay-prompt cycle: pay → 402 → prompt → pay → 402 → … Each cycle is a real
* payment, so "the user can always tap cancel" is not an adequate answer.
*
* Rule: a given (blob, server) pair may prompt at most once per user-initiated
* action. [beginUserAction] resets the ledger; everything downstream of that tap
* — including the post-payment retry — goes through [shouldPrompt].
*/
class PaymentPromptLedger {
private val prompted = mutableSetOf<String>()
/** The user tapped mirror/sync: a fresh budget of one prompt per target. */
fun beginUserAction() {
synchronized(prompted) { prompted.clear() }
}
/**
* True the first time this (blob, server) pair asks for payment in the current
* user action; false on every subsequent 402 from the same pair.
*/
fun shouldPrompt(
hash: String,
server: String,
): Boolean = synchronized(prompted) { prompted.add("$hash|$server") }
}

View File

@@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -224,6 +225,12 @@ fun uriToRoute(
return connectedAppRoute(uri)
}
// A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the
// NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting).
if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) {
return Route.Nip46Signer(connectUri = uri)
}
relayGroupInviteRoute(uri)?.let { return it }
concordInviteRoute(uri)?.let { return it }

View File

@@ -20,103 +20,155 @@
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.SettingsCategoryWithButton
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayDragState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.draggableRelayItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relayDragHandle
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.rememberRelayDragState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer
import com.vitorpamplona.amethyst.ui.theme.DoubleVertPadding
import com.vitorpamplona.amethyst.ui.theme.FeedPadding
import com.vitorpamplona.amethyst.ui.theme.SettingsCategoryFirstModifier
import com.vitorpamplona.amethyst.ui.theme.SettingsCategorySpacingModifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.amethyst.ui.theme.warningColor
import com.vitorpamplona.quartz.utils.Rfc3986
/** Vibrant palette for server monograms; picked deterministically from the host name. */
private val MonogramColors =
listOf(
Color(0xFF8B5CF6),
Color(0xFF0EA5A0),
Color(0xFFE07B00),
Color(0xFF4169E1),
Color(0xFFD16D8F),
Color(0xFF4F9D4F),
Color(0xFFB66605),
Color(0xFF7C6FE0),
)
@Composable
fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) {
fun AllMediaBody(
blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
) {
val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle()
val healthState by blossomServersViewModel.health.collectAsStateWithLifecycle()
val dragState =
rememberRelayDragState(
onMove = { from, to -> blossomServersViewModel.moveServer(from, to) },
itemCount = { blossomServersState.size },
)
// Auto-save the reordering once the drag finishes, rather than on every intermediate swap.
LaunchedEffect(dragState.isDragging) {
if (!dragState.isDragging) blossomServersViewModel.persistPending()
}
LazyColumn(
verticalArrangement = Arrangement.SpaceAround,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier,
contentPadding = FeedPadding,
userScrollEnabled = !dragState.isDragging,
) {
item {
SettingsCategory(
R.string.media_servers_blossom_section,
R.string.media_servers_blossom_explainer,
SettingsCategoryFirstModifier,
SectionLabel(
title = stringRes(id = R.string.media_servers_priority_section),
caption = stringRes(id = R.string.media_servers_reorder_hint),
topPadding = 4.dp,
)
}
renderMediaServerList(
mediaServersState = blossomServersState,
keyType = "blossom",
editLabel = R.string.add_a_blossom_server,
emptyLabel = R.string.no_blossom_server_message,
onAddServer = { server ->
blossomServersViewModel.addServer(server)
},
onDeleteServer = {
blossomServersViewModel.removeServer(serverUrl = it)
},
)
DEFAULT_MEDIA_SERVERS.let {
if (blossomServersState.isEmpty()) {
item {
SettingsCategoryWithButton(
title = R.string.recommended_media_servers,
description = R.string.built_in_servers_description,
modifier = SettingsCategorySpacingModifier,
) {
OutlinedButton(
onClick = {
blossomServersViewModel.addServerList(
it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
)
},
) {
Text(text = stringRes(id = R.string.use_default_servers))
}
}
}
itemsIndexed(
it,
key = { _: Int, server: ServerName ->
"Proposed" + server.baseUrl
},
) { _, server ->
MediaServerEntry(
serverEntry = server,
isAmethystDefault = true,
onAddOrDelete = { serverUrl ->
if (server.type == ServerType.Blossom) {
blossomServersViewModel.addServer(serverUrl)
}
},
Text(
text = stringRes(id = R.string.no_blossom_server_message),
modifier = DoubleVertPadding,
)
}
} else {
itemsIndexed(
blossomServersState,
key = { _, server -> "blossom" + server.baseUrl },
) { index, entry ->
MediaServerRow(
index = index,
serverEntry = entry,
health = healthState[entry.baseUrl] ?: ServerHealth.Unknown,
dragState = dragState,
onDelete = { blossomServersViewModel.removeServer(serverUrl = it) },
)
}
}
item {
AddServerSection(
// Server entries carry their host in `name`; match recommended chips by host so
// normalization differences in the URL don't hide the "added" state.
addedHosts = blossomServersState.mapTo(HashSet()) { it.name },
onAddServer = { blossomServersViewModel.addServer(it) },
onAddAll = {
blossomServersViewModel.addServerList(
DEFAULT_MEDIA_SERVERS.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null },
)
},
)
}
item {
SectionLabel(title = stringRes(id = R.string.media_servers_upload_section))
UploadBehaviorSection(accountViewModel, nav)
}
item {
SectionLabel(title = stringRes(id = R.string.media_servers_cache_section))
MediaCacheSection(accountViewModel)
}
item {
@@ -125,97 +177,449 @@ fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) {
}
}
fun LazyListScope.renderMediaServerList(
mediaServersState: List<ServerName>,
keyType: String,
editLabel: Int,
emptyLabel: Int,
onAddServer: (String) -> Unit,
onDeleteServer: (String) -> Unit,
/**
* Upload-side Blossom controls: mirror uploads across the user's servers (BUD-04),
* optimize via `/media` (BUD-05), and a shortcut into the blob manager.
*/
@Composable
private fun UploadBehaviorSection(
accountViewModel: AccountViewModel,
nav: INav,
) {
if (mediaServersState.isEmpty()) {
item {
Text(
text = stringRes(id = emptyLabel),
modifier = DoubleVertPadding,
)
}
} else {
itemsIndexed(
mediaServersState,
key = { _: Int, server: ServerName ->
keyType + server.baseUrl
},
) { _, entry ->
MediaServerEntry(
serverEntry = entry,
onAddOrDelete = {
onDeleteServer(it)
},
)
}
}
val mirror by accountViewModel.account.settings.mirrorUploadsToAllServers
.collectAsStateWithLifecycle()
val optimize by accountViewModel.account.settings.optimizeMediaOnUpload
.collectAsStateWithLifecycle()
item {
Spacer(modifier = StdVertSpacer)
MediaServerEditField(editLabel) {
onAddServer(it)
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer),
) {
UploadToggleRow(
title = stringRes(id = R.string.blossom_mirror_uploads),
caption = stringRes(id = R.string.blossom_mirror_uploads_caption),
checked = mirror,
onCheckedChange = { accountViewModel.account.settings.changeMirrorUploadsToAllServers(it) },
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 14.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
UploadToggleRow(
title = stringRes(id = R.string.blossom_optimize_media),
caption = stringRes(id = R.string.blossom_optimize_media_caption),
checked = optimize,
onCheckedChange = { accountViewModel.account.settings.changeOptimizeMediaOnUpload(it) },
)
HorizontalDivider(
modifier = Modifier.padding(horizontal = 14.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { nav.nav(Route.ManageBlossomBlobs) }
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.my_blossom_data),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f),
)
Icon(
symbol = MaterialSymbols.Storage,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
@Composable
fun MediaServerEntry(
modifier: Modifier = Modifier,
serverEntry: ServerName,
isAmethystDefault: Boolean = false,
onAddOrDelete: (serverUrl: String) -> Unit,
private fun UploadToggleRow(
title: String,
caption: String,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.padding(vertical = 10.dp),
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Text(
text = caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(checked = checked, onCheckedChange = onCheckedChange)
}
}
/** Compact section header: an accent label with an optional gray caption below. */
@Composable
private fun SectionLabel(
title: String,
caption: String? = null,
topPadding: Dp = 20.dp,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier.fillMaxWidth().padding(top = topPadding, bottom = 8.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
)
if (caption != null) {
Text(
text = caption,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
}
}
/**
* A draggable, ranked server card. Position in the list is the upload/fallback priority
* (row #1 is tried first), so each card carries its rank on the monogram and a drag handle
* wired into the shared [RelayDragState], plus a live reachability dot. The primary target
* (#1) is called out with an accent border, tint, and badge.
*/
@Composable
fun MediaServerRow(
index: Int,
serverEntry: ServerName,
health: ServerHealth,
dragState: RelayDragState,
onDelete: (serverUrl: String) -> Unit,
) {
val isPrimary = index == 0
val shape = RoundedCornerShape(16.dp)
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 5.dp)
.draggableRelayItem(index, dragState)
.clip(shape)
.background(
if (isPrimary) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.06f)
} else {
Color.Transparent
},
).border(
width = if (isPrimary) 1.5.dp else 1.dp,
color = if (isPrimary) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant,
shape = shape,
).padding(start = 6.dp, top = 8.dp, bottom = 8.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.DragIndicator,
contentDescription = stringRes(id = R.string.media_server_reorder),
modifier = Modifier.size(22.dp).relayDragHandle(index, dragState),
tint = MaterialTheme.colorScheme.grayText,
)
Spacer(Modifier.size(8.dp))
ServerAvatar(name = serverEntry.name, rank = index + 1, isPrimary = isPrimary)
Column(
modifier =
Modifier
.weight(1f),
modifier = Modifier.weight(1f).padding(start = 12.dp),
) {
serverEntry.let {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = it.name.replaceFirstChar(Char::titlecase),
text = serverEntry.name.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
)
Spacer(modifier = StdVertSpacer)
Text(
text = it.baseUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
)
if (isPrimary) {
Spacer(Modifier.size(6.dp))
PrimaryBadge()
}
}
Text(
text = serverEntry.baseUrl,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.grayText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.End,
) {
IconButton(
onClick = {
onAddOrDelete(serverEntry.baseUrl)
HealthIndicator(health)
IconButton(onClick = { onDelete(serverEntry.baseUrl) }) {
Icon(
symbol = MaterialSymbols.Delete,
contentDescription = stringRes(id = R.string.delete_media_server),
tint = MaterialTheme.colorScheme.grayText,
)
}
}
}
/**
* Inline "add a server" area: a URL field followed by the recommended servers as a
* horizontal strip of add-chips (already-added ones read as done).
*/
@Composable
private fun AddServerSection(
addedHosts: Set<String>,
onAddServer: (String) -> Unit,
onAddAll: () -> Unit,
) {
SectionLabel(title = stringRes(id = R.string.media_servers_add_section))
// Recommended servers first — one tap adds a known-good host.
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringRes(id = R.string.media_servers_recommended_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onAddAll) {
Text(text = stringRes(id = R.string.use_default_servers))
}
}
LazyRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(vertical = 4.dp),
) {
items(
DEFAULT_MEDIA_SERVERS,
key = { it.baseUrl },
) { server ->
val host = runCatching { Rfc3986.host(server.baseUrl) }.getOrNull()
RecommendedChip(
serverEntry = server,
added = host != null && host in addedHosts,
onAdd = { onAddServer(server.baseUrl) },
)
}
}
// ...or paste any server address.
Text(
text = stringRes(id = R.string.media_servers_add_url_label),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 16.dp, bottom = 8.dp),
)
MediaServerEditField(R.string.add_a_blossom_server) { onAddServer(it) }
}
/** A recommended server as a tappable pill. Once added it reads as done and stops responding. */
@Composable
private fun RecommendedChip(
serverEntry: ServerName,
added: Boolean,
onAdd: () -> Unit,
) {
val shape = RoundedCornerShape(50)
Row(
modifier =
Modifier
.clip(shape)
.then(
if (added) {
Modifier.background(MaterialTheme.colorScheme.surfaceVariant)
} else {
Modifier.border(1.dp, MaterialTheme.colorScheme.outlineVariant, shape)
},
).clickable(enabled = !added, onClick = onAdd)
.padding(start = 6.dp, end = 12.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
ServerMonogram(name = serverEntry.name, size = 24.dp)
Text(
text = serverEntry.name.replaceFirstChar(Char::titlecase),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
)
Icon(
symbol = if (added) MaterialSymbols.CheckCircle else MaterialSymbols.Add,
contentDescription =
if (added) {
stringRes(id = R.string.media_server_added)
} else {
stringRes(id = R.string.add_media_server)
},
modifier = Modifier.size(18.dp),
tint = if (added) MaterialTheme.colorScheme.grayText else MaterialTheme.colorScheme.primary,
)
}
}
/** A colored letter tile identifying a server, derived from its host name. */
@Composable
private fun ServerMonogram(
name: String,
size: Dp,
) {
val letter = name.firstOrNull { it.isLetterOrDigit() }?.uppercaseChar()?.toString() ?: "?"
val color = MonogramColors[((name.hashCode() % MonogramColors.size) + MonogramColors.size) % MonogramColors.size]
Box(
modifier =
Modifier
.size(size)
.clip(RoundedCornerShape(size / 3))
.background(color),
contentAlignment = Alignment.Center,
) {
Text(
text = letter,
style = if (size >= 30.dp) MaterialTheme.typography.labelLarge else MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
)
}
}
/**
* The server's monogram with its priority rank as a small corner badge — one visual unit
* for identity + position. The rank chip is accent-filled for the primary target (#1).
*/
@Composable
private fun ServerAvatar(
name: String,
rank: Int,
isPrimary: Boolean,
) {
Box(modifier = Modifier.size(40.dp)) {
ServerMonogram(name = name, size = 36.dp)
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.background)
.padding(1.5.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.fillMaxSize()
.clip(CircleShape)
.background(
if (isPrimary) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.surfaceVariant
},
),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = if (isAmethystDefault) MaterialSymbols.Add else MaterialSymbols.Delete,
contentDescription =
if (isAmethystDefault) {
stringRes(id = R.string.add_media_server)
Text(
text = rank.toString(),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
color =
if (isPrimary) {
MaterialTheme.colorScheme.onPrimary
} else {
stringRes(id = R.string.delete_media_server)
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
}
}
/** Small "Primary" pill shown on the #1 server. */
@Composable
private fun PrimaryBadge() {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(5.dp))
.background(MaterialTheme.colorScheme.primaryContainer)
.padding(horizontal = 6.dp, vertical = 1.dp),
) {
Text(
text = stringRes(id = R.string.media_server_primary_badge),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer,
)
}
}
/** Colored reachability dot + label, or a spinner while a probe is in flight. */
@Composable
private fun HealthIndicator(health: ServerHealth) {
if (health == ServerHealth.Unknown) return
if (health == ServerHealth.Checking) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.grayText,
)
return
}
val color: Color
val label: Int
when (health) {
ServerHealth.Online -> {
color = MaterialTheme.colorScheme.allGoodColor
label = R.string.media_server_status_online
}
ServerHealth.Slow -> {
color = MaterialTheme.colorScheme.warningColor
label = R.string.media_server_status_slow
}
else -> {
color = MaterialTheme.colorScheme.error
label = R.string.media_server_status_offline
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
Box(
modifier =
Modifier
.size(9.dp)
.clip(CircleShape)
.background(color),
)
Text(
text = stringRes(id = label),
style = MaterialTheme.typography.labelSmall,
color = color,
maxLines = 1,
)
}
}

View File

@@ -20,7 +20,9 @@
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.consumeWindowInsets
@@ -28,6 +30,9 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -39,15 +44,18 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
@Composable
@@ -63,9 +71,7 @@ fun AllMediaServersScreen(
blossomServersViewModel.load()
}
MediaServersScaffold(blossomServersViewModel, accountViewModel) {
nav.popBack()
}
MediaServersScaffold(blossomServersViewModel, accountViewModel, nav)
}
@OptIn(ExperimentalMaterial3Api::class)
@@ -73,24 +79,20 @@ fun AllMediaServersScreen(
fun MediaServersScaffold(
blossomServersViewModel: BlossomServersViewModel,
accountViewModel: AccountViewModel,
onClose: () -> Unit,
nav: INav,
) {
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.media_servers,
onCancel = {
blossomServersViewModel.refresh()
onClose()
},
onPost = {
blossomServersViewModel.saveFileServers()
onClose()
},
TopBarWithBackButton(
caption = stringRes(id = R.string.media_servers),
nav = nav,
)
},
) { padding ->
Column(
AllMediaBody(
blossomServersViewModel = blossomServersViewModel,
accountViewModel = accountViewModel,
nav = nav,
modifier =
Modifier
.fillMaxSize()
@@ -101,43 +103,49 @@ fun MediaServersScaffold(
bottom = padding.calculateBottomPadding(),
).consumeWindowInsets(padding)
.imePadding(),
verticalArrangement = Arrangement.spacedBy(10.dp, alignment = Alignment.Top),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = stringRes(id = R.string.set_preferred_media_servers),
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 10.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.grayText,
)
LocalBlossomCacheToggle(accountViewModel)
HorizontalDivider()
AllMediaBody(blossomServersViewModel)
}
)
}
}
/**
* The on-device Blossom cache, rendered as a self-contained card so it reads as its
* own feature rather than a stray toggle. Binds to the same two account settings as
* before.
*/
@Composable
private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
fun MediaCacheSection(accountViewModel: AccountViewModel) {
val enabled by accountViewModel.account.settings.useLocalBlossomCache
.collectAsStateWithLifecycle()
val profilePicturesOnly by accountViewModel.account.settings.localBlossomCacheProfilePicturesOnly
.collectAsStateWithLifecycle()
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
.collectAsStateWithLifecycle()
Column(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer),
) {
Row(
modifier = Modifier.fillMaxWidth(),
modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Box(
modifier =
Modifier
.size(36.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
Icon(
symbol = MaterialSymbols.Storage,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
Column(modifier = Modifier.weight(1f).padding(start = 14.dp, end = 12.dp)) {
Text(
text = stringRes(id = R.string.use_local_blossom_cache),
style = MaterialTheme.typography.bodyLarge,
@@ -147,18 +155,6 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
Text(
text =
if (enabled && probeAvailable) {
stringRes(id = R.string.local_blossom_cache_detected)
} else if (enabled) {
stringRes(id = R.string.local_blossom_cache_not_detected)
} else {
""
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Switch(
checked = enabled,
@@ -167,11 +163,21 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
}
if (enabled) {
CacheDetectionChip(accountViewModel)
HorizontalDivider(
modifier = Modifier.padding(start = 64.dp),
color = MaterialTheme.colorScheme.outlineVariant,
)
Row(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp),
modifier =
Modifier
.fillMaxWidth()
.padding(start = 64.dp, end = 14.dp, top = 12.dp, bottom = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(
text = stringRes(id = R.string.local_blossom_cache_profile_pics_only),
style = MaterialTheme.typography.bodyMedium,
@@ -192,3 +198,39 @@ private fun LocalBlossomCacheToggle(accountViewModel: AccountViewModel) {
}
}
}
/**
* Loopback-detection status for the local cache, shown only while the cache is enabled
* (kept in its own composable so the loopback probe is subscribed only then).
*/
@Composable
private fun CacheDetectionChip(accountViewModel: AccountViewModel) {
val probeAvailable by accountViewModel.useLocalBlossomBridgeForProfilePics
.collectAsStateWithLifecycle()
val color = if (probeAvailable) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.grayText
Row(
modifier = Modifier.padding(start = 64.dp, end = 14.dp, bottom = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Box(
modifier =
Modifier
.size(8.dp)
.clip(CircleShape)
.background(color),
)
Text(
text =
if (probeAvailable) {
stringRes(id = R.string.local_blossom_cache_detected)
} else {
stringRes(id = R.string.local_blossom_cache_not_detected)
},
style = MaterialTheme.typography.labelMedium,
color = color,
)
}
}

View File

@@ -0,0 +1,510 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import android.content.Intent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
import com.vitorpamplona.quartz.nip56Reports.ReportType
@Composable
fun BlossomBlobManagerScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val vm: BlossomBlobManagerViewModel = viewModel()
vm.init(accountViewModel)
LaunchedEffect(accountViewModel) { vm.refresh() }
val blobs by vm.blobs.collectAsStateWithLifecycle()
val loading by vm.isLoading.collectAsStateWithLifecycle()
val error by vm.error.collectAsStateWithLifecycle()
val pendingPayment by vm.pendingPayment.collectAsStateWithLifecycle()
pendingPayment?.let { pending ->
BlossomPaymentDialog(
host = pending.targetHost,
amountSats = pending.amountSats,
reason = pending.payment.sanitizedReason(),
onConfirm = { vm.confirmPendingPayment() },
onDismiss = { vm.cancelPendingPayment() },
)
}
Scaffold(
topBar = {
TopBarExtensibleWithBackButton(
title = { Text(stringRes(R.string.my_blossom_data)) },
showBackButton = nav.canPop(),
popBack = { nav.popBack() },
actions = {
IconButton(onClick = { vm.refresh() }, enabled = !loading) {
if (loading) {
CircularProgressIndicator(modifier = Modifier.size(22.dp), strokeWidth = 2.dp)
} else {
Icon(symbol = MaterialSymbols.Sync, contentDescription = stringRes(R.string.blossom_refresh))
}
}
},
)
},
) { padding ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(
top = padding.calculateTopPadding(),
bottom = padding.calculateBottomPadding(),
),
) {
when {
loading && blobs.isEmpty() -> CenteredState { CircularProgressIndicator() }
error != null && blobs.isEmpty() ->
CenteredState {
StatusGlyph(MaterialSymbols.Warning, MaterialTheme.colorScheme.error)
Spacer(Modifier.height(12.dp))
Text(error ?: "", color = MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(8.dp))
OutlinedButton(onClick = { vm.refresh() }) { Text(stringRes(R.string.retry)) }
}
blobs.isEmpty() ->
CenteredState {
StatusGlyph(MaterialSymbols.Storage, MaterialTheme.colorScheme.grayText)
Spacer(Modifier.height(12.dp))
Text(
stringRes(R.string.manage_stored_files_empty),
color = MaterialTheme.colorScheme.grayText,
)
}
else ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (blobs.any { it.hasMissing }) {
item { SyncAllBanner(onSyncAll = { vm.syncAll() }) }
}
items(blobs, key = { it.hash }) { row ->
BlobCard(row, vm)
}
}
}
}
}
}
@Composable
private fun CenteredState(content: @Composable () -> Unit) {
Column(
modifier = Modifier.fillMaxSize().padding(32.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) { content() }
}
@Composable
private fun StatusGlyph(
symbol: MaterialSymbol,
tint: Color,
) {
Box(
modifier =
Modifier
.size(72.dp)
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
contentAlignment = Alignment.Center,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(34.dp), tint = tint)
}
}
@Composable
private fun SyncAllBanner(onSyncAll: () -> Unit) {
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainerHighest)
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
Text(stringRes(R.string.blossom_sync_gaps), style = MaterialTheme.typography.bodyMedium)
}
FilledTonalButton(onClick = onSyncAll) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_sync_all))
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun BlobCard(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
) {
var menuOpen by remember { mutableStateOf(false) }
var reportOpen by remember { mutableStateOf(false) }
val clipboard = LocalClipboardManager.current
val context = LocalContext.current
Column(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(20.dp))
.background(MaterialTheme.colorScheme.surfaceContainer)
.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Header: thumbnail / file glyph + hash + overflow menu.
Row(verticalAlignment = Alignment.CenterVertically) {
BlobThumbnail(row)
Column(modifier = Modifier.weight(1f).padding(horizontal = 12.dp)) {
Text(
text = row.hash.take(12) + "" + row.hash.takeLast(6),
style = MaterialTheme.typography.titleSmall,
fontFamily = FontFamily.Monospace,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
)
Text(
text = listOfNotNull(row.type, row.size?.let { humanBytes(it) }).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
)
}
Box {
IconButton(onClick = { menuOpen = true }) {
Icon(symbol = MaterialSymbols.MoreVert, contentDescription = null)
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
if (row.url != null) {
DropdownMenuItem(
text = { Text(stringRes(R.string.copy)) },
leadingIcon = { MenuIcon(MaterialSymbols.ContentCopy) },
onClick = {
menuOpen = false
clipboard.setText(AnnotatedString(row.url))
},
)
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_open)) },
leadingIcon = { MenuIcon(MaterialSymbols.AutoMirrored.OpenInNew) },
onClick = {
menuOpen = false
runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, row.url.toUri())) }
},
)
}
if (row.hasPresent) {
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_report)) },
leadingIcon = { MenuIcon(MaterialSymbols.Report) },
onClick = {
menuOpen = false
reportOpen = true
},
)
HorizontalDivider()
row.presentServers.forEach { server ->
DropdownMenuItem(
text = { Text(stringRes(R.string.blossom_delete_from_host, vm.hostOf(server))) },
leadingIcon = { MenuIcon(MaterialSymbols.Delete, MaterialTheme.colorScheme.error) },
onClick = {
menuOpen = false
vm.delete(row.hash, server)
},
)
}
}
}
}
}
// Per-server presence pills (green = has it, grey = missing, spinner = working).
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
row.servers.forEach { ServerPill(it) }
}
// Primary CTA: fill the gaps.
if (row.hasMissing && row.url != null) {
FilledTonalButton(
onClick = { vm.mirrorToMissing(row) },
modifier = Modifier.fillMaxWidth(),
) {
Icon(symbol = MaterialSymbols.CloudUpload, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.blossom_mirror_to_missing))
}
}
}
if (reportOpen) {
BlossomReportDialog(row = row, vm = vm, onDismiss = { reportOpen = false })
}
}
@Composable
private fun BlobThumbnail(row: BlobRow) {
val shape = RoundedCornerShape(12.dp)
if (row.url != null && row.type?.startsWith("image/") == true) {
AsyncImage(
model = row.url,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.size(48.dp).clip(shape),
)
} else {
Box(
modifier = Modifier.size(48.dp).clip(shape).background(MaterialTheme.colorScheme.secondaryContainer),
contentAlignment = Alignment.Center,
) {
val glyph = if (row.type?.startsWith("video/") == true) MaterialSymbols.Download else MaterialSymbols.Storage
Icon(
symbol = glyph,
contentDescription = null,
modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
}
}
@Composable
private fun ServerPill(presence: ServerPresence) {
val present = presence.state == PresenceState.PRESENT
val pending = presence.state == PresenceState.PENDING
val accent = MaterialTheme.colorScheme.allGoodColor
val bg =
if (present) accent.copy(alpha = 0.12f) else MaterialTheme.colorScheme.surfaceContainerHighest
Row(
modifier = Modifier.clip(CircleShape).background(bg).padding(horizontal = 10.dp, vertical = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (pending) {
CircularProgressIndicator(
modifier = Modifier.size(9.dp),
strokeWidth = 1.5.dp,
color = MaterialTheme.colorScheme.primary,
)
} else {
val dot = if (present) accent else MaterialTheme.colorScheme.grayText
Box(modifier = Modifier.size(7.dp).clip(CircleShape).background(dot))
}
Text(
text = presence.host,
style = MaterialTheme.typography.labelMedium,
color = if (present) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.grayText,
)
}
}
@Composable
private fun MenuIcon(
symbol: MaterialSymbol,
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
) {
Icon(symbol = symbol, contentDescription = null, modifier = Modifier.size(20.dp), tint = tint)
}
private fun humanBytes(bytes: Long): String =
when {
bytes >= 1_000_000 -> "${bytes / 1_000_000} MB"
bytes >= 1_000 -> "${bytes / 1_000} KB"
else -> "$bytes B"
}
@Composable
private fun BlossomPaymentDialog(
host: String,
amountSats: Long?,
reason: String?,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, tint = MaterialTheme.colorScheme.allGoodColor) },
title = { Text(stringRes(R.string.blossom_payment_title)) },
text = {
Column {
Text(text = stringRes(R.string.blossom_payment_message, host))
// X-Reason is server-controlled: it is sanitized upstream and rendered
// here attributed to the server, in a dimmer italic, so it can never be
// mistaken for Amethyst's own wording (e.g. a fake "Pay 1 sat").
reason?.let {
Spacer(Modifier.size(12.dp))
Text(
text = stringRes(R.string.blossom_payment_server_says, host, it),
style = MaterialTheme.typography.bodySmall,
fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
confirmButton = {
FilledTonalButton(onClick = onConfirm) {
Icon(symbol = MaterialSymbols.Bolt, contentDescription = null, modifier = Modifier.size(18.dp))
Spacer(Modifier.size(8.dp))
Text(
if (amountSats != null) {
pluralStringResource(R.plurals.blossom_pay_sats, amountSats.toInt(), amountSats.toInt())
} else {
stringRes(R.string.blossom_pay)
},
)
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}
@Composable
private fun BlossomReportDialog(
row: BlobRow,
vm: BlossomBlobManagerViewModel,
onDismiss: () -> Unit,
) {
var comment by remember { mutableStateOf("") }
var typeMenuOpen by remember { mutableStateOf(false) }
var type by remember { mutableStateOf(ReportType.OTHER) }
// Report to the first server that actually holds the blob.
val server = row.presentServers.firstOrNull() ?: return
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(symbol = MaterialSymbols.Report, contentDescription = null) },
title = { Text(stringRes(R.string.blossom_report_title)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Box {
OutlinedButton(onClick = { typeMenuOpen = true }, modifier = Modifier.fillMaxWidth()) {
Text(type.code)
}
DropdownMenu(expanded = typeMenuOpen, onDismissRequest = { typeMenuOpen = false }) {
ReportType.entries.forEach { rt ->
DropdownMenuItem(
text = { Text(rt.code) },
onClick = {
type = rt
typeMenuOpen = false
},
)
}
}
}
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
label = { Text(stringRes(R.string.blossom_report_comment_hint)) },
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
FilledTonalButton(onClick = {
vm.report(row.hash, server, type, comment)
onDismiss()
}) { Text(stringRes(R.string.blossom_send)) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) }
},
)
}

View File

@@ -0,0 +1,472 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomMirrorQueue
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomPaymentHandler
import com.vitorpamplona.amethyst.service.uploads.blossom.PaymentPromptLedger
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentProof
import com.vitorpamplona.quartz.nipB7Blossom.BlossomPaymentRequired
import com.vitorpamplona.quartz.nipB7Blossom.BlossomReport
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.nipB7Blossom.BlossomUploadResult
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlin.coroutines.cancellation.CancellationException
/** Whether one of the user's servers holds a blob, or an operation on it is in flight. */
enum class PresenceState {
/** Confirmed present (green). */
PRESENT,
/** Confirmed absent (grey). */
MISSING,
/** An operation is in flight — first-load HEAD probe, a mirror, or a delete (spinner). */
PENDING,
}
@Immutable
data class ServerPresence(
val server: String,
val host: String,
val state: PresenceState,
)
/**
* One stored blob, plus the state of each of the user's Blossom servers for it —
* the "seen files per server" view (BUD-01 HEAD / BUD-02 list).
*/
@Immutable
data class BlobRow(
val hash: HexKey,
val url: String?,
val size: Long?,
val type: String?,
val servers: List<ServerPresence>,
) {
val presentServers get() = servers.filter { it.state == PresenceState.PRESENT }.map { it.server }
val missingServers get() = servers.filter { it.state == PresenceState.MISSING }.map { it.server }
val hasPresent get() = servers.any { it.state == PresenceState.PRESENT }
val hasMissing get() = servers.any { it.state == PresenceState.MISSING }
val presentCount get() = servers.count { it.state == PresenceState.PRESENT }
}
/** A BUD-07 payment prompt raised while mirroring [hash] to [target]. */
@Immutable
data class PendingMirrorPayment(
val hash: HexKey,
val sourceUrl: String,
val target: String,
val targetHost: String,
val payment: BlossomPaymentRequired,
val amountSats: Long?,
)
/**
* Backs the Blossom blob-manager screen. For the active account it fans a
* `GET /list/<pubkey>` (BUD-02) across every server in the user's kind-10063 list
* in parallel, shows the result immediately, then backfills only the servers that
* do NOT implement `/list` with parallel `HEAD /<sha256>` probes (BUD-01) — a server
* that returned a list already reports its full holdings, so no probe is needed for
* it. Delete (BUD-02), mirror-to-missing (BUD-04) and report (BUD-09) update the
* affected pill in place (spinner → green/grey) instead of reloading the screen.
*/
@Stable
class BlossomBlobManagerViewModel : ViewModel() {
private lateinit var account: Account
private val _blobs = MutableStateFlow<List<BlobRow>>(emptyList())
val blobs = _blobs.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading = _isLoading.asStateFlow()
private val _error = MutableStateFlow<String?>(null)
val error = _error.asStateFlow()
private val _pendingPayment = MutableStateFlow<PendingMirrorPayment?>(null)
val pendingPayment = _pendingPayment.asStateFlow()
private var resultCollectorStarted = false
/**
* Caps BUD-07 payment prompts at one per (blob, server) per user-initiated
* mirror, so a server that keeps replying 402 after being paid cannot drive an
* unbounded pay-prompt cycle. See [PaymentPromptLedger].
*/
private val promptLedger = PaymentPromptLedger()
fun init(accountViewModel: AccountViewModel) {
this.account = accountViewModel.account
// Reflect the app-level sync sweep's per-server results onto the pills, so an
// open manager turns dots green live even though the work runs in the background.
if (!resultCollectorStarted) {
resultCollectorStarted = true
viewModelScope.launch {
Amethyst.instance.blossomMirrorQueue.results.collect { r ->
setServerState(r.hash, r.server, if (r.ok) PresenceState.PRESENT else PresenceState.MISSING)
}
}
}
}
private fun clientFor(server: String) = BlossomClient(Amethyst.instance.roleBasedHttpClientBuilder.okHttpClientForUploads(server))
// The user's explicitly configured kind-10063 servers (empty if they never set
// a list) — not the DEFAULT_MEDIA_SERVERS fallback, so the matrix reflects the
// servers the user actually chose.
private fun servers(): List<String> =
account.blossomServers.flow.value
.distinct()
private var refreshJob: Job? = null
fun refresh() {
// Cancel any in-flight load so two quick refreshes can't interleave their writes
// or fight over _isLoading.
refreshJob?.cancel()
refreshJob =
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
try {
loadMatrix()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("BlossomBlobManager", "Failed to load blob list", e)
_error.value = e.message?.ifBlank { null } ?: e.javaClass.simpleName
} finally {
_isLoading.value = false
}
}
}
private suspend fun loadMatrix() {
val pubkey = account.signer.pubKey
val servers = servers()
if (servers.isEmpty()) {
_blobs.value = emptyList()
return
}
// Phase 1 — /list every server in parallel. A null result means the server
// doesn't implement /list (or errored) and needs HEAD backfill in phase 2.
val listed: List<Pair<String, List<BlossomUploadResult>?>> =
coroutineScope {
servers
.map { server ->
async {
server to
try {
val auth = account.createBlossomListAuth("List blobs").toAuthorizationHeader()
clientFor(server).list(server, pubkey, auth)
} catch (e: Exception) {
Log.w("BlossomBlobManager", "list failed on $server", e)
null
}
}
}.awaitAll()
}
val meta = HashMap<HexKey, BlobMeta>()
val hashesByServer = HashMap<String, Set<HexKey>>()
val listCapable = HashSet<String>()
listed.forEach { (server, blobs) ->
if (blobs != null) {
listCapable.add(server)
hashesByServer[server] = blobs.mapNotNull { it.sha256 }.toSet()
blobs.forEach { d -> d.sha256?.let { meta.putIfAbsent(it, BlobMeta(d.url, d.size, d.type)) } }
}
}
val allHashes = meta.keys.toList()
fun rows(head: Map<Pair<String, HexKey>, Boolean> = emptyMap()): List<BlobRow> =
allHashes
.map { hash ->
val presences =
servers.map { server ->
val state =
when {
server in listCapable ->
if (hash in hashesByServer[server].orEmpty()) PresenceState.PRESENT else PresenceState.MISSING
else ->
head[server to hash]?.let { if (it) PresenceState.PRESENT else PresenceState.MISSING }
?: PresenceState.PENDING
}
ServerPresence(server, hostOf(server), state)
}
val m = meta[hash]
BlobRow(hash, m?.url, m?.size, m?.type, presences)
}.sortedByDescending { it.presentCount }
// Show the /list result right away; the non-list servers appear as spinning
// pills until their HEAD probe lands.
_blobs.value = rows()
_isLoading.value = false
// Phase 2 — HEAD-probe ONLY the non-list servers, for the known hashes. Bounded so a
// user with hundreds of blobs on a non-/list server doesn't spawn hundreds of probes
// at once; OkHttp still caps per-host, this caps the coroutine/allocation breadth.
val nonListServers = servers.filter { it !in listCapable }
if (nonListServers.isNotEmpty() && allHashes.isNotEmpty()) {
val limiter = Semaphore(MAX_HEAD_PROBES)
val head =
coroutineScope {
nonListServers
.flatMap { server ->
allHashes.map { hash ->
async { limiter.withPermit { (server to hash) to clientFor(server).has(hash, server) } }
}
}.awaitAll()
}.toMap()
_blobs.value = rows(head)
}
}
private fun currentRow(hash: HexKey) = _blobs.value.firstOrNull { it.hash == hash }
private fun setServerState(
hash: HexKey,
server: String,
state: PresenceState,
) {
// Atomic read-modify-write: the app-level sync results collector (Main) and the
// per-row delete/mirror actions (IO) both mutate _blobs concurrently, so a plain
// `_blobs.value = _blobs.value.map{}` would lose updates.
_blobs.update { list ->
list.map { row ->
if (row.hash != hash) {
row
} else {
row.copy(servers = row.servers.map { if (it.server == server) it.copy(state = state) else it })
}
}
}
}
/** BUD-02 delete: remove [hash] from a single [server]; the pill spins then goes grey. */
fun delete(
hash: HexKey,
server: String,
) {
viewModelScope.launch(Dispatchers.IO) {
setServerState(hash, server, PresenceState.PENDING)
val ok =
try {
val auth = account.createBlossomDeleteAuth(hash, "Delete blob").toAuthorizationHeader()
clientFor(server).delete(hash, server, auth)
} catch (e: Exception) {
Log.w("BlossomBlobManager", "delete failed on $server", e)
false
}
setServerState(hash, server, if (ok) PresenceState.MISSING else PresenceState.PRESENT)
// Drop the row entirely once it's gone from every server.
_blobs.update { list -> if (list.firstOrNull { it.hash == hash }?.hasPresent == false) list.filter { it.hash != hash } else list }
}
}
/**
* BUD-04: mirror a blob to every server that doesn't have it; each pill spins
* then turns green. This is the user-initiated entry point, so it resets the
* "already asked for payment" ledger — see [promptedForPayment].
*/
fun mirrorToMissing(row: BlobRow) {
promptLedger.beginUserAction()
mirrorMissingTargets(row)
}
private fun mirrorMissingTargets(row: BlobRow) {
val source = row.url ?: return
val targets = currentRow(row.hash)?.missingServers ?: row.missingServers
if (targets.isEmpty()) return
viewModelScope.launch(Dispatchers.IO) {
for (target in targets) {
setServerState(row.hash, target, PresenceState.PENDING)
try {
mirrorOne(source, row.hash, row.size, target, null)
setServerState(row.hash, target, PresenceState.PRESENT)
} catch (e: BlossomPaymentException) {
setServerState(row.hash, target, PresenceState.MISSING)
if (BlossomPaymentHandler.canPay(account, e.payment)) {
// Bounded: a server that pockets the preimage and replies 402
// again must not be able to spin up an endless pay-prompt
// cycle. One prompt per target per user-initiated mirror.
if (!promptLedger.shouldPrompt(row.hash, target)) {
Log.w("BlossomBlobManager", "mirror to $target asked for payment again after being paid; not re-prompting")
_error.value = "${hostOf(target)} asked for payment again after being paid. Amethyst stopped to avoid paying twice."
continue
}
_pendingPayment.value =
PendingMirrorPayment(row.hash, source, target, hostOf(target), e.payment, BlossomPaymentHandler.amountSats(e.payment))
return@launch
}
Log.w("BlossomBlobManager", "mirror to $target needs unsupported payment", e)
} catch (e: Exception) {
setServerState(row.hash, target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "mirror to $target failed", e)
}
}
}
}
/**
* BUD-04 sweep: hand the whole "fill every gap" job to the app-level
* [BlossomMirrorQueue] so it keeps running (with a floating progress banner) as
* the user navigates away. The pills we're about to fill go straight to a spinner;
* the queue's [results] stream (collected in [init]) flips each to green/grey as it
* lands, so an open manager stays in sync with the background sweep.
*/
fun syncAll() {
val tasks =
_blobs.value
.filter { it.hasMissing && it.url != null }
.map { BlossomMirrorQueue.Task(it.hash, it.url!!, it.size, it.missingServers) }
if (tasks.isEmpty()) return
_blobs.update { list ->
list.map { row ->
if (!row.hasMissing) {
row
} else {
row.copy(servers = row.servers.map { if (it.state == PresenceState.MISSING) it.copy(state = PresenceState.PENDING) else it })
}
}
}
Amethyst.instance.blossomMirrorQueue.start(account, tasks)
}
private suspend fun mirrorOne(
source: String,
hash: HexKey,
size: Long?,
target: String,
proof: BlossomPaymentProof?,
) {
val auth = account.createBlossomUploadAuth(hash, size ?: 0L, "Mirror $hash").toAuthorizationHeader()
clientFor(target).mirror(source, target, auth, proof)
}
/** User confirmed the BUD-07 prompt: pay via the wallet, retry that server, then continue. */
fun confirmPendingPayment() {
val pending = _pendingPayment.value ?: return
_pendingPayment.value = null
viewModelScope.launch(Dispatchers.IO) {
setServerState(pending.hash, pending.target, PresenceState.PENDING)
// pending.amountSats is exactly what the dialog showed; pay() re-derives
// the amount from the invoice and refuses if the two disagree or the
// amount is above the cap.
val result = BlossomPaymentHandler.pay(account, pending.payment, pending.amountSats)
val proof =
when (result) {
is BlossomPaymentHandler.PayResult.Paid -> result.proof
is BlossomPaymentHandler.PayResult.Refused -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = result.reason
return@launch
}
BlossomPaymentHandler.PayResult.TimedOut -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value =
"Your wallet did not confirm in time. If the payment does go through, retry the mirror — " +
"Amethyst will not send this invoice again."
return@launch
}
BlossomPaymentHandler.PayResult.Unavailable -> {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
_error.value = "Payment failed or was not confirmed by the wallet."
return@launch
}
}
try {
mirrorOne(pending.sourceUrl, pending.hash, currentRow(pending.hash)?.size, pending.target, proof)
setServerState(pending.hash, pending.target, PresenceState.PRESENT)
} catch (e: Exception) {
setServerState(pending.hash, pending.target, PresenceState.MISSING)
Log.w("BlossomBlobManager", "paid mirror to ${pending.target} failed", e)
}
// Continue with any remaining missing servers. Targets already prompted
// in this action (including this one) will NOT prompt again.
currentRow(pending.hash)?.let { mirrorMissingTargets(it) }
}
}
fun cancelPendingPayment() {
_pendingPayment.value = null
}
/** BUD-09: report a blob to a server as problematic content. */
fun report(
hash: HexKey,
server: String,
type: ReportType,
comment: String,
onDone: (Boolean) -> Unit = {},
) {
viewModelScope.launch(Dispatchers.IO) {
val ok =
try {
val event = account.signer.sign(BlossomReport.build(hash, type, account.signer.pubKey, comment))
clientFor(server).report(server, event.toJson())
} catch (e: Exception) {
Log.w("BlossomBlobManager", "report failed on $server", e)
false
}
withContext(Dispatchers.Main) { onDone(ok) }
}
}
fun hostOf(serverBaseUrl: String): String = BlossomServerUrl.domain(serverBaseUrl)
private data class BlobMeta(
val url: String?,
val size: Long?,
val type: String?,
)
companion object {
/** Cap on concurrent HEAD probes during the /list backfill. */
private const val MAX_HEAD_PROBES = 8
}
}

View File

@@ -24,9 +24,11 @@ import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.privacyOptions.IRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.Rfc3986
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
@@ -36,18 +38,26 @@ import kotlinx.coroutines.launch
class BlossomServersViewModel : ViewModel() {
private lateinit var accountViewModel: AccountViewModel
private lateinit var account: Account
private var httpClientBuilder: IRoleBasedHttpClientBuilder? = null
private val _fileServers = MutableStateFlow<List<ServerName>>(emptyList())
val fileServers = _fileServers.asStateFlow()
/** Reachability status per server, keyed by [ServerName.baseUrl]. */
private val _health = MutableStateFlow<Map<String, ServerHealth>>(emptyMap())
val health = _health.asStateFlow()
private var isModified = false
fun init(accountViewModel: AccountViewModel) {
this.accountViewModel = accountViewModel
this.account = accountViewModel.account
this.httpClientBuilder = accountViewModel.httpClientBuilder
}
fun load() {
refresh()
checkAllHealth()
}
fun refresh() {
@@ -67,15 +77,63 @@ class BlossomServersViewModel : ViewModel() {
}
}
}
pruneHealth()
}
fun addServerList(serverList: List<String>) {
serverList.forEach { serverUrl ->
addServer(serverUrl)
/** Moves a server to a new position; list order is the upload/fallback priority. */
fun moveServer(
from: Int,
to: Int,
) {
_fileServers.update { list ->
if (from !in list.indices || to !in list.indices) return@update list
list.toMutableList().apply { add(to, removeAt(from)) }
}
isModified = true
}
/** Re-probes every server currently in the list. Fresh cached results are reused. */
fun checkAllHealth() {
_fileServers.value.forEach { probeServer(it.baseUrl) }
}
private fun probeServer(serverUrl: String) {
val builder = httpClientBuilder ?: return
// A probe is already in flight for this URL — don't launch a duplicate.
if (_health.value[serverUrl] == ServerHealth.Checking) return
// Reuse a still-fresh cached status instead of hitting the network again.
MediaServerHealthProbe.cached(serverUrl)?.let { cachedStatus ->
_health.update { it + (serverUrl to cachedStatus) }
return
}
_health.update { it + (serverUrl to ServerHealth.Checking) }
viewModelScope.launch(Dispatchers.IO) {
val result = MediaServerHealthProbe.probe(serverUrl, builder::okHttpClientForPreview)
_health.update { it + (serverUrl to result) }
}
}
/** Drops health entries for servers no longer in the list so the map can't grow unbounded. */
private fun pruneHealth() {
val liveUrls = _fileServers.value.mapTo(HashSet()) { it.baseUrl }
_health.update { statuses -> statuses.filterKeys { it in liveUrls } }
}
fun addServerList(serverList: List<String>) {
var added = false
serverList.forEach { if (addServerInternal(it)) added = true }
if (added) persist()
}
fun addServer(serverUrl: String) {
if (addServerInternal(serverUrl)) persist()
}
/** Adds a server to the in-memory list (no persist). Returns true if it was new. */
private fun addServerInternal(serverUrl: String): Boolean {
val normalizedUrl =
try {
Rfc3986.normalize(serverUrl.trim())
@@ -94,14 +152,12 @@ class BlossomServersViewModel : ViewModel() {
normalizedUrl,
ServerType.Blossom,
)
if (_fileServers.value.contains(serverRef)) {
return
} else {
_fileServers.update {
it.plus(serverRef)
}
}
if (_fileServers.value.contains(serverRef)) return false
_fileServers.update { it.plus(serverRef) }
probeServer(serverRef.baseUrl)
isModified = true
return true
}
fun removeServer(
@@ -120,22 +176,24 @@ class BlossomServersViewModel : ViewModel() {
ServerName(serverName, serverUrl, ServerType.Blossom),
)
}
pruneHealth()
isModified = true
persist()
}
}
fun removeAllServers() {
_fileServers.update { emptyList() }
isModified = true
}
/**
* Publishes any pending change. Called after each discrete edit (add/remove) and,
* for a reorder, once the drag gesture completes — so a drag doesn't publish a
* kind-10063 event on every intermediate swap.
*/
fun persistPending() = persist()
fun saveFileServers() {
if (isModified) {
accountViewModel.launchSigner {
val serverList = _fileServers.value.map { it.baseUrl }
account.sendBlossomServersList(serverList)
refresh()
}
private fun persist() {
if (!isModified) return
isModified = false
accountViewModel.launchSigner {
account.sendBlossomServersList(_fileServers.value.map { it.baseUrl })
}
}

View File

@@ -0,0 +1,139 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomSyncState
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.allGoodColor
import com.vitorpamplona.amethyst.ui.theme.grayText
/**
* App-wide floating banner for the BUD-04 "sync all" sweep, mounted at the navigation
* root (a sibling of the mining/broadcast banners) so it floats over every screen and
* survives navigation. Observes the app-level [Amethyst.instance.blossomMirrorQueue].
*/
@Composable
fun DisplayBlossomSyncProgress() {
val queue = Amethyst.instance.blossomMirrorQueue
val state by queue.state.collectAsStateWithLifecycle()
// Retain the last non-null value so the slide-out exit still has content to draw when
// state clears to null on cancel/dismiss.
var lastShown by remember { mutableStateOf<BlossomSyncState?>(null) }
LaunchedEffect(state) { state?.let { lastShown = it } }
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomCenter) {
AnimatedVisibility(
visible = state != null,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier =
Modifier
.navigationBarsPadding()
.padding(start = 12.dp, end = 12.dp, bottom = 116.dp)
.widthIn(max = 560.dp),
) {
val s = lastShown ?: return@AnimatedVisibility
Surface(
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 3.dp,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.padding(14.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
symbol = if (s.running) MaterialSymbols.CloudUpload else MaterialSymbols.Check,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = if (s.running) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.allGoodColor,
)
Text(
text = if (s.running) stringRes(R.string.blossom_syncing) else stringRes(R.string.blossom_sync_done),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(horizontal = 10.dp),
)
IconButton(
onClick = { if (s.running) queue.cancel() else queue.dismiss() },
modifier = Modifier.size(28.dp),
) {
Icon(symbol = MaterialSymbols.Close, contentDescription = null, modifier = Modifier.size(18.dp))
}
}
LinearProgressIndicator(
progress = { s.fraction },
modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
)
Text(
text =
buildString {
append("${s.done} / ${s.total}")
if (s.failed > 0) append(" · ${s.failed} failed")
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.grayText,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
}

View File

@@ -0,0 +1,135 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.actions.mediaServers
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServerUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
/**
* Reachability status of a media server, shown as a colored dot next to each
* entry in the Media Servers list.
*/
enum class ServerHealth {
/** Not probed yet. */
Unknown,
/** A probe is in flight. */
Checking,
/** Responded quickly. */
Online,
/** Responded, but slower than [MediaServerHealthProbe.SLOW_THRESHOLD_MS]. */
Slow,
/** Could not be reached (DNS, refused, timeout, TLS). */
Offline,
}
/**
* A one-shot, lightweight reachability check for a Blossom server. Issues a `HEAD` to
* the server's `/upload` endpoint (BUD-01/BUD-02) and classifies the outcome by
* round-trip time. Any HTTP response — even 401/404/405 — counts as reachable; only
* connection-level failures map to [ServerHealth.Offline].
*
* The `/upload` path is probed rather than the bare root because CDN-fronted hosts
* (e.g. cdn.satellite.earth) don't answer `/` at all and would time out, wrongly
* reading as offline even though uploads work. `/upload` is the endpoint that
* actually matters for a media upload target.
*
* Mirrors the timeout/short-circuit shape of
* [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe],
* but runs per-server and returns latency-classified status rather than a boolean.
*/
object MediaServerHealthProbe {
/** Round-trip time above which a reachable server is reported as [ServerHealth.Slow]. */
const val SLOW_THRESHOLD_MS: Long = 1_000L
private const val PROBE_TIMEOUT_MS: Long = 5_000L
/**
* How long a probe result is reused before the server is re-checked. The cache is
* process-wide (this is a singleton) so results survive the screen's ViewModel being
* recreated on each open, mirroring [com.vitorpamplona.amethyst.service.uploads.blossom.bud10.LocalBlossomCacheProbe].
*/
private const val CACHE_TTL_MS: Long = 60_000L
private class CachedResult(
val status: ServerHealth,
val atMs: Long,
)
private val cache = ConcurrentHashMap<String, CachedResult>()
/** The cached status for [baseUrl] if still within [CACHE_TTL_MS], else null. */
fun cached(baseUrl: String): ServerHealth? {
val entry = cache[baseUrl] ?: return null
return if (TimeUtils.nowMillis() - entry.atMs < CACHE_TTL_MS) entry.status else null
}
suspend fun probe(
baseUrl: String,
clientForUrl: (String) -> OkHttpClient,
): ServerHealth {
cached(baseUrl)?.let { return it }
val result = runProbe(baseUrl, clientForUrl)
cache[baseUrl] = CachedResult(result, TimeUtils.nowMillis())
return result
}
private suspend fun runProbe(
baseUrl: String,
clientForUrl: (String) -> OkHttpClient,
): ServerHealth =
try {
val client =
clientForUrl(baseUrl)
.newBuilder()
.connectTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.callTimeout(PROBE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
val request =
Request
.Builder()
.url(BlossomServerUrl.upload(baseUrl))
.head()
.build()
val startedAt = TimeUtils.nowMillis()
client.newCall(request).executeAsync().use {
// The status code doesn't matter — /upload commonly answers 401/404/405
// without auth. Getting any response back proves the host is reachable.
val elapsed = TimeUtils.nowMillis() - startedAt
if (elapsed > SLOW_THRESHOLD_MS) ServerHealth.Slow else ServerHealth.Online
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
ServerHealth.Offline
}
}

View File

@@ -88,40 +88,13 @@ fun ConcordInviteCard(
onClick = { nav.nav(Route.ConcordInvite(linkText)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
ConcordInvitePreviewRow(
robotSeed = robotSeed,
title = title,
subtitle = stringRes(R.string.concord_invite_card_subtitle),
accountViewModel = accountViewModel,
autoPlayGif = autoPlayGif,
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = stringRes(R.string.concord_invite_card_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
SymbolIcon(
symbol = MaterialSymbols.ChevronRight,
contentDescription = stringRes(R.string.concord_invite_card_join),
@@ -131,3 +104,57 @@ fun ConcordInviteCard(
}
}
}
/**
* The avatar + title/subtitle row shared by [ConcordInviteCard] (in note content) and
* the deep-link consent screen, so both render an invite identically. Purely
* presentational — it performs no I/O, which is what lets the deep-link screen show a
* preview without contacting the link's (attacker-supplied) relays before the user
* consents.
*/
@Composable
fun ConcordInvitePreviewRow(
robotSeed: String,
title: String,
subtitle: String,
accountViewModel: AccountViewModel,
autoPlayGif: Boolean,
trailing: @Composable () -> Unit = {},
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
RobohashFallbackAsyncImage(
robot = robotSeed,
model = null,
contentDescription = title,
modifier =
Modifier
.size(52.dp)
.clip(CircleShape)
.border(1.5.dp, MaterialTheme.colorScheme.primary.copy(alpha = 0.35f), CircleShape),
loadProfilePicture = accountViewModel.settings.showProfilePictures(),
loadRobohash = accountViewModel.settings.isNotPerformanceMode(),
autoPlayGif = autoPlayGif,
)
Column(Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
trailing()
}
}

View File

@@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.LoadRelayGroupChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupCardWarmupSubscription
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink
@@ -93,7 +93,7 @@ private fun RelayGroupCardContent(
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayGroupWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupWarmup, accountViewModel)
RelayGroupCardWarmupSubscription(baseChannel, accountViewModel.dataSources().relayGroupCardWarmup, accountViewModel)
// Recompose in place when the relay-signed metadata / roster changes.
val channelState by baseChannel

View File

@@ -54,8 +54,6 @@ private data class NoteLayoutPx(
val authorWidth: Int,
/** Horizontal gap between author column and content column: 10dp */
val authorContentGap: Int,
/** Vertical gap between author picture and relay badges: 5dp */
val authorBadgeGap: Int,
/** Vertical spacer between header rows and note content: 4dp */
val contentSpacer: Int,
/** Left padding for the content area: 12dp */
@@ -87,9 +85,6 @@ private fun NoteComposeLayoutPreviewCard() {
.background(Color(0xFF9575CD)),
)
},
relayBadges = {
Text("R1 R2 R3", fontSize = 10.sp, color = Color.Gray)
},
firstRow = {
Text(
"Alice @alice 2h ...",
@@ -127,7 +122,6 @@ private fun NoteComposeLayoutBoostedPreview() {
addPadding = false,
showAuthorColumn = false,
authorPicture = {},
relayBadges = {},
firstRow = {
Text("Bob boosted 2h ...")
},
@@ -157,10 +151,8 @@ private fun NoteComposeLayoutBoostedPreview() {
* │ │author│ gap │ firstRow │ │
* │ │ 55dp │ │ secondRow (optional) │ │
* │ │ │ │ 4dp spacer (optional) │ │
* │ ──────┤ 5dp │ noteContent │ │
* │ │relay │ gap │ │
* │ │badges│ │ │ │
* │ └──────┘ └────────────────────────────┘ │
* │ ────── │ noteContent │ │
* │ └────────────────────────────┘
* ├───────────────────────────────────────────────┤
* │ reactionsRow (full width, no side padding) │
* └───────────────────────────────────────────────┘
@@ -200,11 +192,10 @@ private fun NoteComposeLayoutBoostedPreview() {
*
* The `contents` list and `allMeasurables` indices are:
* - 0: [authorPicture] - 0 or 1 measurables
* - 1: [relayBadges] - 0 or 1 measurables
* - 2: [firstRow] - exactly 1 measurable
* - 3: [secondRow] - 0 or 1 measurables
* - 4: [noteContent] - 1+ measurables (stacked vertically)
* - 5: [reactionsRow] - 0+ measurables (stacked vertically)
* - 1: [firstRow] - exactly 1 measurable
* - 2: [secondRow] - 0 or 1 measurables
* - 3: [noteContent] - 1+ measurables (stacked vertically)
* - 4: [reactionsRow] - 0+ measurables (stacked vertically)
*
* ## Performance
*
@@ -212,7 +203,7 @@ private fun NoteComposeLayoutBoostedPreview() {
* - Eliminates 3 layout node levels (Row, author Column, content Column)
* - Eliminates Row's two-pass measurement (measure author first, then content)
* - Pre-computes all pixel dimensions once via [remember], cached across recompositions
* - Remaining per-frame allocations: ~48 bytes for `listOf(6 lambdas)` +
* - Remaining per-frame allocations: ~40 bytes for `listOf(5 lambdas)` +
* ~24 bytes per `arrayOfNulls` for multi-child slots
*
* The caller should use `drawBehind { drawRect(color) }` instead of
@@ -231,8 +222,6 @@ private fun NoteComposeLayoutBoostedPreview() {
* note content. False for repost events.
* @param authorPicture Slot for the author's profile picture (55x55dp area).
* Should emit nothing when [showAuthorColumn] is false to skip composition.
* @param relayBadges Slot for relay indicator icons below the author picture.
* Should emit nothing when [showAuthorColumn] is false.
* @param firstRow Slot for the primary header: author name, time, more options.
* Always present.
* @param secondRow Slot for the secondary header: NIP-05, location, PoW, OTS.
@@ -250,7 +239,6 @@ fun NoteComposeLayout(
showSecondRow: Boolean = false,
showContentSpacer: Boolean = true,
authorPicture: @Composable () -> Unit,
relayBadges: @Composable () -> Unit,
firstRow: @Composable () -> Unit,
secondRow: @Composable () -> Unit,
noteContent: @Composable () -> Unit,
@@ -263,7 +251,6 @@ fun NoteComposeLayout(
NoteLayoutPx(
authorWidth = Size55dp.roundToPx(),
authorContentGap = 10.dp.roundToPx(),
authorBadgeGap = 5.dp.roundToPx(),
contentSpacer = 4.dp.roundToPx(),
padStart = 12.dp.roundToPx(),
padEnd = 12.dp.roundToPx(),
@@ -277,11 +264,10 @@ fun NoteComposeLayout(
contents =
listOf(
authorPicture, // 0
relayBadges, // 1
firstRow, // 2
secondRow, // 3
noteContent, // 4
reactionsRow, // 5
firstRow, // 1
secondRow, // 2
noteContent, // 3
reactionsRow, // 4
),
modifier = modifier,
) { allMeasurables, constraints ->
@@ -308,14 +294,13 @@ fun NoteComposeLayout(
// Single-child slots: firstOrNull() returns null for empty slots (hidden),
// skipping measurement entirely.
val authorPlaceable = allMeasurables[0].firstOrNull()?.measure(authorConstraints)
val relayPlaceable = allMeasurables[1].firstOrNull()?.measure(authorConstraints)
val firstRowPlaceable = allMeasurables[2].firstOrNull()?.measure(contentConstraints)
val firstRowPlaceable = allMeasurables[1].firstOrNull()?.measure(contentConstraints)
val secondRowPlaceable =
if (showSecondRow) allMeasurables[3].firstOrNull()?.measure(contentConstraints) else null
if (showSecondRow) allMeasurables[2].firstOrNull()?.measure(contentConstraints) else null
// Multi-child slots: measured into pre-sized arrays to avoid List allocation.
val contentMeasurables = allMeasurables[4]
val contentMeasurables = allMeasurables[3]
val contentPlaceables = arrayOfNulls<Placeable>(contentMeasurables.size)
var contentStackHeight = 0
for (i in contentMeasurables.indices) {
@@ -324,7 +309,7 @@ fun NoteComposeLayout(
contentStackHeight += placeable.height
}
val reactionsMeasurables = allMeasurables[5]
val reactionsMeasurables = allMeasurables[4]
val reactionsPlaceables = arrayOfNulls<Placeable>(reactionsMeasurables.size)
var reactionsHeight = 0
for (i in reactionsMeasurables.indices) {
@@ -344,7 +329,7 @@ fun NoteComposeLayout(
val authorColumnHeight =
if (showAuthorColumn && authorPlaceable != null) {
authorPlaceable.height + px.authorBadgeGap + (relayPlaceable?.height ?: 0)
authorPlaceable.height
} else {
0
}
@@ -357,10 +342,6 @@ fun NoteComposeLayout(
// Author column (inside padding area)
if (showAuthorColumn) {
authorPlaceable?.placeRelative(padStart, padTop)
relayPlaceable?.placeRelative(
padStart,
padTop + (authorPlaceable?.height ?: 0) + px.authorBadgeGap,
)
}
// Content column (to the right of author, inside padding area)

View File

@@ -56,6 +56,8 @@ import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAler
import com.vitorpamplona.amethyst.service.resourceusage.ScreenTimeIntegrator
import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.AllMediaServersScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.BlossomBlobManagerScreen
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DisplayBlossomSyncProgress
import com.vitorpamplona.amethyst.ui.actions.paymentTargets.PaymentTargetsScreen
import com.vitorpamplona.amethyst.ui.broadcast.DisplayBroadcastProgress
import com.vitorpamplona.amethyst.ui.call.CallActivity
@@ -77,6 +79,7 @@ import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen
import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageFileScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsImageScreen
import com.vitorpamplona.amethyst.ui.note.share.ShareNoteAsQrScreen
import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -151,6 +154,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.N
import com.vitorpamplona.amethyst.ui.screen.loggedIn.drafts.DraftListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabAccountWatcher
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher
@@ -258,6 +262,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen
@@ -330,6 +336,10 @@ fun AppNavigation(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val bottomBarItems by accountViewModel.settings.uiSettingsFlow.bottomBarItems
.collectAsStateWithLifecycle()
// Move every embedded app to the new account on a switch. Mounted before the layer and
// the preloader so the previous account's sessions are dropped ahead of the first sweep
// (an embed WebView's storage profile is fixed at construction, so it must be rebuilt).
EmbeddedTabAccountWatcher()
EmbeddedTabLayer(bottomBarItems.favoriteIds())
// Warm every pinned tab at startup so the first tap is instant (content already local).
EmbeddedTabPreloader(accountViewModel)
@@ -348,6 +358,7 @@ fun AppNavigation(
DisplayCrashMessages(accountViewModel, nav)
DisplayResourceUsageAlert(accountViewModel, nav)
DisplayBroadcastProgress(accountViewModel)
DisplayBlossomSyncProgress()
ObserveIncomingCalls(accountViewModel)
}
@@ -423,6 +434,8 @@ fun BuildNavigation(
composableFromEndArgs<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEndArgs<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav, it.connectUri) }
composableFromEnd<Route.Nip46ConnectedApps> { Nip46ConnectedAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -557,6 +570,7 @@ fun BuildNavigation(
composableFromEnd<Route.RequestToVanish> { RequestToVanishScreen(accountViewModel, nav) }
composableFromEnd<Route.VanishEvents> { VanishEventsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditMediaServers> { AllMediaServersScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ManageBlossomBlobs> { BlossomBlobManagerScreen(accountViewModel, nav) }
composableFromEndArgs<Route.EditNestsServers> {
com.vitorpamplona.amethyst.ui.actions.nestsServers
.NestsServersScreen(accountViewModel, nav)
@@ -570,6 +584,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.Note> { ThreadScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImage> { ShareNoteAsImageScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsImageFile> { ShareNoteAsImageFileScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ShareNoteAsQr> { ShareNoteAsQrScreen(it.id, accountViewModel, nav) }
composableFromEndArgs<Route.ContactListUsers> { ContactListUsersScreen(it.noteId, accountViewModel, nav) }
composableFromEndArgs<Route.Hashtag> { HashtagScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.Geohash> { GeoHashScreen(it, accountViewModel, nav) }

View File

@@ -46,8 +46,10 @@ enum class NavBarItem {
DRAFTS,
SCHEDULED_POSTS,
INTEREST_SETS,
BLOSSOM_DATA,
EMOJI_PACKS,
WALLET,
NOSTR_SIGNER,
COMMUNITIES,
ARTICLES,
PICTURES,
@@ -181,6 +183,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AutoAwesome,
resolveRoute = { Route.EditFavoriteAlgoFeeds },
),
NavBarItem.BLOSSOM_DATA to
NavBarItemDef(
id = NavBarItem.BLOSSOM_DATA,
labelRes = R.string.my_blossom_data,
icon = MaterialSymbols.Storage,
resolveRoute = { Route.ManageBlossomBlobs },
),
NavBarItem.EMOJI_PACKS to
NavBarItemDef(
id = NavBarItem.EMOJI_PACKS,
@@ -195,6 +204,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
icon = MaterialSymbols.AccountBalanceWallet,
resolveRoute = { Route.Wallet },
),
NavBarItem.NOSTR_SIGNER to
NavBarItemDef(
id = NavBarItem.NOSTR_SIGNER,
labelRes = R.string.nip46_signer_title,
icon = MaterialSymbols.Key,
resolveRoute = { Route.Nip46Signer() },
),
NavBarItem.COMMUNITIES to
NavBarItemDef(
id = NavBarItem.COMMUNITIES,
@@ -441,8 +457,10 @@ val DrawerYouItems: List<NavBarItem> =
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
/**
@@ -493,8 +511,10 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
),
),
NavBarCategory(

View File

@@ -107,6 +107,13 @@ sealed class Route {
@Serializable object ConnectedApps : Route()
@Serializable data class Nip46Signer(
/** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */
val connectUri: String? = null,
) : Route()
@Serializable object Nip46ConnectedApps : Route()
@Serializable object RelayAuthSettings : Route()
@Serializable data class ConnectedAppDetail(
@@ -458,6 +465,8 @@ sealed class Route {
@Serializable object EditMediaServers : Route()
@Serializable object ManageBlossomBlobs : Route()
@Serializable object EditNestsServers : Route()
@Serializable object EditFavoriteAlgoFeeds : Route()
@@ -498,6 +507,10 @@ sealed class Route {
val id: String,
) : Route()
@Serializable data class ShareNoteAsQr(
val id: String,
) : Route()
@Serializable data class ContactListUsers(
val noteId: String,
) : Route()

View File

@@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -72,6 +73,8 @@ import com.vitorpamplona.amethyst.commons.ui.components.LoadingAnimation
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingGeohash
import com.vitorpamplona.amethyst.ui.note.creators.location.GeohashLocationPickerDialog
import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName
import com.vitorpamplona.amethyst.ui.screen.CommunityName
import com.vitorpamplona.amethyst.ui.screen.FavoriteAlgoFeedName
@@ -129,85 +132,108 @@ fun FeedFilterSpinner(
val openDropdownLabel = stringRes(R.string.open_dropdown_menu)
Box(
modifier = modifier,
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier)
val filter = selected?.code ?: placeholderCode
// Bound the Column so long filter names (e.g. DVM titles) get truncated
// instead of wrapping to multiple lines and shoving the expand icon out.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f, fill = false),
) {
val filter = selected?.code
if (filter is TopFilter.Geohash) {
LoadCityName(
geohashStr = filter.tag,
onLoading = {
Row {
Text(
text = filter.tag,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier.weight(1f, fill = false),
contentAlignment = Alignment.Center,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Spacer(modifier = Size20Modifier)
// Bound the Column so long filter names (e.g. DVM titles) get truncated
// instead of wrapping to multiple lines and shoving the expand icon out.
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.weight(1f, fill = false),
) {
if (filter is TopFilter.Geohash) {
LoadCityName(
geohashStr = filter.tag,
onLoading = {
Row {
Text(
text = filter.tag,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
Text(
text = cityName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
Text(
text = cityName,
text = currentText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} else {
Text(
text = currentText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
if (filter is TopFilter.AroundMe) {
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
if (filter is TopFilter.AroundMe) {
val locationPermissionState = rememberPermissionState(Manifest.permission.ACCESS_COARSE_LOCATION)
if (!locationPermissionState.status.isGranted) {
LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() }
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle()
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} else {
val location by Amethyst.instance.locationManager.geohashStateFlow
.collectAsStateWithLifecycle()
when (val myLocation = location) {
is LocationState.LocationResult.Success -> {
LoadCityName(
geohashStr = myLocation.geoHash.toString(),
onLoading = {
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
when (val myLocation = location) {
is LocationState.LocationResult.Success -> {
LoadCityName(
geohashStr = myLocation.geoHash.toString(),
onLoading = {
Row {
Text(
text = "(${myLocation.geoHash})",
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = StdHorzSpacer)
LoadingAnimation(indicatorSize = 12.dp, circleWidth = 2.dp)
}
},
) { cityName ->
Text(
text = "($cityName)",
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
LocationState.LocationResult.LackPermission -> {
Text(
text = "($cityName)",
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
@@ -215,58 +241,46 @@ fun FeedFilterSpinner(
)
}
}
LocationState.LocationResult.LackPermission -> {
Text(
text = stringRes(R.string.lack_location_permissions),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
LocationState.LocationResult.Loading -> {
Text(
text = stringRes(R.string.loading_location),
fontSize = Font12SP,
lineHeight = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
}
Icon(
symbol = MaterialSymbols.ExpandMore,
contentDescription = explainer,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
Icon(
symbol = MaterialSymbols.ExpandMore,
contentDescription = explainer,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.placeholderText,
)
}
Box(
modifier =
Modifier
.matchParentSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
optionsShowing = true
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = openDropdownLabel) {
optionsShowing = true
return@onClick true
}
},
)
}
Box(
modifier =
Modifier
.matchParentSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
optionsShowing = true
}.semantics {
role = Role.DropdownList
stateDescription = accessibilityDescription
onClick(label = openDropdownLabel) {
optionsShowing = true
return@onClick true
}
},
)
// Save/unsave the active place to the followed-locations list (kind 10081), so a
// teleported spot can be kept as a permanent chip — the "add to my interests" step.
if (filter is TopFilter.Geohash) {
FollowLocationToggle(filter.tag, accountViewModel)
}
}
var teleporting by remember { mutableStateOf(false) }
if (optionsShowing && options.isNotEmpty()) {
GroupedFeedFilterDialog(
title = explainer,
@@ -274,12 +288,60 @@ fun FeedFilterSpinner(
onDismiss = { optionsShowing = false },
onSelect = { definition ->
optionsShowing = false
onSelect(definition)
// "Teleport" isn't a real filter — open the map picker and apply the chosen
// place as this screen's Geohash filter via the normal onSelect path.
if (definition.code is TopFilter.TeleportPicker) {
teleporting = true
} else {
onSelect(definition)
}
},
) {
RenderOption(it.name, accountViewModel)
}
}
if (teleporting) {
GeohashLocationPickerDialog(
initialGeohash = (selected?.code as? TopFilter.Geohash)?.tag,
onDismiss = { teleporting = false },
onConfirm = { cell ->
teleporting = false
onSelect(FeedDefinition(code = TopFilter.Geohash(cell), name = GeoHashName(cell)))
},
)
}
}
/** A compact toggle in the filter header to follow/unfollow the active geohash location. */
@Composable
private fun FollowLocationToggle(
tag: String,
accountViewModel: AccountViewModel,
) {
val isFollowing by observeUserIsFollowingGeohash(tag, accountViewModel)
IconButton(onClick = {
if (!accountViewModel.isWriteable()) {
accountViewModel.toastManager.toast(
R.string.read_only_user,
if (isFollowing) R.string.login_with_a_private_key_to_be_able_to_unfollow else R.string.login_with_a_private_key_to_be_able_to_follow,
)
} else if (isFollowing) {
accountViewModel.unfollowGeohash(tag)
} else {
accountViewModel.followGeohash(tag)
}
}) {
Icon(
symbol = if (isFollowing) MaterialSymbols.Bookmark else MaterialSymbols.BookmarkAdd,
contentDescription =
stringRes(
if (isFollowing) R.string.unfollow_geohash else R.string.follow_geohash,
),
modifier = Size20Modifier,
tint = if (isFollowing) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@Composable
@@ -366,6 +428,7 @@ private fun FeedDefinition.group(): FeedGroup =
is ResourceName -> {
when (code) {
is TopFilter.AroundMe -> FeedGroup.LOCATIONS
is TopFilter.TeleportPicker -> FeedGroup.LOCATIONS
is TopFilter.Global -> FeedGroup.RELAYS
is TopFilter.Selected -> FeedGroup.RELAYS
is TopFilter.AllFavoriteAlgoFeeds -> FeedGroup.DVMS
@@ -516,6 +579,10 @@ private fun FeedIcon(
MaterialSymbols.LocationOn
}
is TopFilter.TeleportPicker -> {
MaterialSymbols.TravelExplore
}
is TopFilter.AllFollows -> {
MaterialSymbols.Groups
}

View File

@@ -0,0 +1,163 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.topbars
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Top bar for the All Settings screen that hosts the settings filter directly in the app bar,
* replacing the static "Settings" title. The back arrow is still shown only when there is
* something to pop (i.e. the user arrived from a deep/profile link rather than the bottom nav,
* which clears the stack). This keeps the shared [TopBarWithBackButton] untouched for every other
* screen while giving Settings a search-as-app-bar layout.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsSearchTopBar(
query: String,
onQueryChange: (String) -> Unit,
onClear: () -> Unit,
nav: INav,
) {
ShorterTopAppBar(
expandedHeight = SettingsSearchTopBarHeight,
title = {
CompactSettingsSearchField(
query = query,
onQueryChange = onQueryChange,
onClear = onClear,
modifier =
Modifier
.fillMaxWidth()
.padding(end = 12.dp),
)
},
navigationIcon = {
// Suppress the back arrow when this is the bottom of the back stack (bottom-nav entry
// clears it with popUpTo(route) { inclusive = true }); show it for deep-link entries.
if (nav.canPop()) {
IconButton(nav::popBack) {
ArrowBackIcon()
}
}
},
)
}
/**
* A compact, pill-shaped search field sized to sit inside the app bar. It filters the settings
* list in place rather than opening a results overlay, so it is a plain [BasicTextField] with a
* custom decoration box (a full [androidx.compose.material3.TextField] enforces a ~56dp min height
* that does not fit an app bar).
*/
@Composable
private fun CompactSettingsSearchField(
query: String,
onQueryChange: (String) -> Unit,
onClear: () -> Unit,
modifier: Modifier = Modifier,
) {
val colorScheme = MaterialTheme.colorScheme
BasicTextField(
value = query,
onValueChange = onQueryChange,
modifier = modifier,
singleLine = true,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = colorScheme.onSurface),
cursorBrush = SolidColor(colorScheme.primary),
decorationBox = { innerTextField ->
Row(
modifier =
Modifier
.fillMaxWidth()
.height(SettingsSearchFieldHeight)
.clip(CircleShape)
.background(colorScheme.surfaceContainerHigh)
.padding(start = 12.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
symbol = MaterialSymbols.Search,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = colorScheme.onSurfaceVariant,
)
Box(
modifier =
Modifier
.weight(1f)
.padding(horizontal = 8.dp),
contentAlignment = Alignment.CenterStart,
) {
if (query.isEmpty()) {
Text(
text = stringRes(R.string.settings_search_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
innerTextField()
}
if (query.isNotEmpty()) {
IconButton(onClick = onClear) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.clear),
modifier = Modifier.size(20.dp),
tint = LocalContentColor.current,
)
}
}
}
},
)
}
private val SettingsSearchTopBarHeight = 56.dp
private val SettingsSearchFieldHeight = 42.dp

View File

@@ -0,0 +1,152 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.MediumRelayIconModifier
import com.vitorpamplona.amethyst.ui.theme.NotificationIconModifier
import com.vitorpamplona.amethyst.ui.theme.Size20dp
import com.vitorpamplona.amethyst.ui.theme.Size35dp
import com.vitorpamplona.amethyst.ui.theme.StdStartPadding
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
/**
* Observes the relays the note was seen on, throttled and de-duplicated for the
* "accepted by relays" gallery line. Exposed as State so the caller can both gate
* the gallery's visibility on it and feed it to [RenderAcceptedByRelaysGallery]
* from a single subscription.
*/
@OptIn(FlowPreview::class)
@Composable
internal fun observeNoteRelays(baseNote: Note): State<ImmutableList<NormalizedRelayUrl>> {
// Cold wrapper: `flow()` must be re-resolved on every collection start. A memory
// trim destroys the NoteFlowSet while the lifecycle is stopped; a stateFlow
// captured in remember would then be orphaned and never see another relay update.
// Sampled at 500ms because relay arrivals churn while a note is actively fanning out.
val flow =
remember(baseNote) {
flow { emitAll(baseNote.flow().relays.stateFlow) }
.sample(500)
.map { it.note.relays.toImmutableList() }
.distinctUntilChanged()
}
val initial = remember(baseNote) { baseNote.relays.toImmutableList() }
return flow.collectAsStateWithLifecycle(initial)
}
/**
* Lightweight "does this note have any relays" observer for the reaction row's
* expand-button gate, which runs for every note in the feed. Unlike
* [observeNoteRelays] it maps to a Boolean (no per-emission list allocation) and
* needs no throttling, since the emptiness flips at most a couple of times.
*/
@Composable
internal fun observeNoteHasRelays(baseNote: Note): State<Boolean> {
val flow =
remember(baseNote) {
flow { emitAll(baseNote.flow().relays.stateFlow) }
.map { it.note.relays.isNotEmpty() }
.distinctUntilChanged()
}
return flow.collectAsStateWithLifecycle(baseNote.relays.isNotEmpty())
}
/**
* The "accepted by relays" line of the reaction gallery: the favicon of every relay
* the note was seen on. This is the same relay-pill set that Complete UI Mode used to
* paint below the author avatar; it now lives in the expanded reaction gallery so it
* is available to everyone, not just Complete mode.
*/
@Composable
internal fun RenderAcceptedByRelaysGallery(
relays: ImmutableList<NormalizedRelayUrl>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Row(Modifier.fillMaxWidth()) {
// NotificationIconModifier (55dp wide, 5dp end padding) matches the category-icon
// column of the zap/like/nutzap gallery rows so the Dns icon lines up with them.
Box(modifier = NotificationIconModifier) {
Icon(
symbol = MaterialSymbols.Dns,
contentDescription = stringRes(id = R.string.accepted_by_relays),
modifier = Modifier.size(Size20dp).align(Alignment.TopEnd),
tint = MaterialTheme.colorScheme.placeholderText,
)
}
RelayGallery(relays, nav, accountViewModel)
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun RelayGallery(
relays: ImmutableList<NormalizedRelayUrl>,
nav: INav,
accountViewModel: AccountViewModel,
) {
Column(modifier = StdStartPadding) {
FlowRow {
relays.forEach { relay ->
// Match the 35dp author pictures of the sibling zap/boost/reaction lines.
RenderRelay(
relay = relay,
accountViewModel = accountViewModel,
nav = nav,
boxSize = Size35dp,
iconModifier = MediumRelayIconModifier,
)
}
}
}
}

View File

@@ -63,6 +63,9 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann
import com.vitorpamplona.amethyst.commons.ui.components.GenericLoadable
import com.vitorpamplona.amethyst.commons.ui.note.HeaderPill
import com.vitorpamplona.amethyst.commons.ui.note.QuietMark
import com.vitorpamplona.amethyst.commons.ui.note.RenderCashuMint
import com.vitorpamplona.amethyst.commons.ui.note.RenderFedimint
import com.vitorpamplona.amethyst.commons.ui.note.RenderMintRecommendation
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedStateAsync
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
@@ -127,7 +130,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarCollectionEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarDateSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarRSVPEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCalendarTimeSlotEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCashuMint
import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage
import com.vitorpamplona.amethyst.ui.note.types.RenderChat
import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage
@@ -137,7 +139,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds
import com.vitorpamplona.amethyst.ui.note.types.RenderCodeSnippetEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity
import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack
import com.vitorpamplona.amethyst.ui.note.types.RenderFedimint
import com.vitorpamplona.amethyst.ui.note.types.RenderFhirResource
import com.vitorpamplona.amethyst.ui.note.types.RenderFundraiser
import com.vitorpamplona.amethyst.ui.note.types.RenderGitIssueEvent
@@ -156,7 +157,6 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderLnZap
import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent
import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingRoomEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderMeetingSpaceEvent
import com.vitorpamplona.amethyst.ui.note.types.RenderMintRecommendation
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicPlaylist
import com.vitorpamplona.amethyst.ui.note.types.RenderMusicTrack
import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse
@@ -745,11 +745,6 @@ fun InnerNoteWithReactions(
}
}
},
relayBadges = {
if (notBoostedNorQuote) {
BadgeBox(baseNote, accountViewModel, nav)
}
},
firstRow = {
FirstUserInfoRow(
baseNote = baseNote,
@@ -2075,21 +2070,6 @@ fun observeEdits(
return editState
}
@Composable
fun BadgeBox(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
if (accountViewModel.settings.isCompleteUIMode()) {
if (baseNote.event is RepostEvent || baseNote.event is GenericRepostEvent) {
baseNote.replyTo?.lastOrNull()?.let { RelayBadges(it, accountViewModel, nav) }
} else {
RelayBadges(baseNote, accountViewModel, nav)
}
}
}
@Composable
fun RenderAuthorImages(
baseNote: Note,

View File

@@ -523,10 +523,13 @@ private fun WatchReactionsZapsBoostsAndDisplayIfExists(
content: @Composable () -> Unit,
) {
val hasReactions by observeNoteReferences(baseNote, accountViewModel)
val hasRelays by observeNoteHasRelays(baseNote)
val hasZapraiser = (baseNote.event?.zapraiserAmount() ?: 0) > 0
if (hasReactions || hasZapraiser) {
// The gallery always carries an "accepted by relays" line when the note has been
// seen on any relay, so the expand button must be reachable in that case too.
if (hasReactions || hasZapraiser || hasRelays) {
content()
}
}
@@ -564,8 +567,13 @@ private fun ReactionDetailGallery(
val backgroundColor = remember { mutableStateOf(defaultBackgroundColor) }
val hasReactions by observeNoteReferences(baseNote, accountViewModel)
val relays by observeNoteRelays(baseNote)
if (hasReactions) {
// The gallery shows whenever there is anything to display: the "accepted by relays"
// line (relays are almost always present once a note is seen) or any zap/boost/
// reaction line. Guarding here keeps the padded Row from rendering an empty strip
// for a note that has neither.
if (hasReactions || relays.isNotEmpty()) {
Row(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(start = 10.dp, top = 5.dp),
@@ -576,6 +584,9 @@ private fun ReactionDetailGallery(
WatchOnchainZapsAndRenderGallery(baseNote, nav, accountViewModel)
WatchBoostsAndRenderGallery(baseNote, nav, accountViewModel)
WatchReactionsAndRenderGallery(baseNote, nav, accountViewModel)
if (relays.isNotEmpty()) {
RenderAcceptedByRelaysGallery(relays, nav, accountViewModel)
}
}
}
}

View File

@@ -37,7 +37,6 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -57,7 +56,6 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.ShowMoreRelaysButtonBoxModifer
import com.vitorpamplona.amethyst.ui.theme.Size17Modifier
import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.ui.theme.noteComposeRelayBox
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.FlowPreview
@@ -67,26 +65,6 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.sample
@Composable
fun RelayBadges(
baseNote: Note,
accountViewModel: AccountViewModel,
nav: INav,
) {
val expanded = remember { mutableStateOf(false) }
CrossfadeIfEnabled(expanded.value, modifier = noteComposeRelayBox, label = "RelayBadges", accountViewModel = accountViewModel) {
if (it) {
RenderAllRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav)
} else {
Column {
RenderClosedRelayList(baseNote, Modifier.fillMaxWidth(), accountViewModel = accountViewModel, nav = nav)
ShouldShowExpandButton(baseNote, accountViewModel) { ShowMoreRelaysButton { expanded.value = true } }
}
}
}
}
@OptIn(ExperimentalLayoutApi::class, FlowPreview::class)
@Composable
fun RenderAllRelayList(

Some files were not shown because too many files have changed in this diff Show More