Compare commits

..
Author SHA1 Message Date
Vitor PamplonaandGitHub e10f76665f Merge pull request #3846 from vitorpamplona/claude/30382-rank-follower-providers-j2cnb9
fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
2026-08-01 21:17:36 -04:00
Claude 6cd91bb058 fix: request follower-count provider's 30382 cards in UserCardsSubAssembler
updateFilter only added the rank provider to the trusted-author set, so
when the follower-count provider differed (different pubkey and/or
relay), its kind:30382 cards were never requested from the relay.
followerCountStrFlow then filtered for a signer whose cards never
arrived and rendered "--" forever.

Add liveUserFollowerCount (with its relayUrl) into the same mapOfSet
block, symmetrically with liveUserRankProvider.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7AA1AxqA6StnPVdjGDcwv
2026-08-02 00:43:27 +00:00
Vitor PamplonaandGitHub 7922f9a4d1 Merge pull request #3845 from vitorpamplona/claude/explained-filter-test-warnings-clpcv8
Test: Type ExplainedFilter.copy() result as base Filter
2026-08-01 14:08:56 -04:00
Claude 0cc3f72f55 fix: resolve always-true is-check and redundant cast warnings in ExplainedFilterTest
Declaring 'advanced' as the base Filter type keeps the copy() regression
guard as a genuine runtime assertion instead of a compile-time triviality,
which is what the compiler was warning about.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012KG9YkeFp6zyth5J364DLF
2026-08-01 18:03:03 +00:00
Vitor PamplonaandGitHub 31c3eaf2b3 Merge pull request #3844 from vitorpamplona/claude/code-quality-class-decoupling-rrt199
refactor: decouple LocalCache and Account god classes (behavior-preserving)
2026-08-01 13:31:51 -04:00
Claude 92ca11a583 chore: move stray NIP-29 section comment to AccountRelayGroupActions
The relay-group section header and joinRelayGroup KDoc were left
dangling at the end of AccountConcordActions when the clusters were
split into separate files; reattach them to the function they describe.
Found by the post-refactor audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:27:56 +00:00
Claude dc5e4562fd fix: restore two Marmot log messages mangled during extraction
The account-qualification regex in the AccountMarmotActions extraction
also rewrote 'marmotManager is NULL' to 'account.marmotManager is NULL'
inside two log string literals, changing log output text. Restore the
original wording. Found by the post-refactor equivalence audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:22:31 +00:00
Vitor PamplonaandGitHub e0ea365368 Merge pull request #3843 from vitorpamplona/fix/rtt-open-from-handshake
rtt-open is the transport's handshake, not our own queueing
2026-08-01 13:22:11 -04:00
Claude 1dba215d4d refactor: extract AccountZapActions from Account
Moves the ~270-line zap/payment orchestration (NIP-57 zap requests,
NWC wallet requests with spoof tracking, NIP-B1 BOLT12 zaps, NIP-BC
onchain zaps/sends/splits) into AccountZapActions, exposed as
account.zaps. The onchain backend-not-configured constant moves with
it. External callers (ZapPaymentHandler, V4VPaymentHandler, wallet
viewmodels, blossom payments, app functions) now call account.zaps.*
directly. Moved code is unchanged except for account. qualification.

Completes the Account decoupling series: Account.kt went from 6228 to
3618 lines across EventBroadcaster, AccountConcordActions,
AccountMarmotActions, AccountRelayGroupActions, and AccountZapActions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:16:51 +00:00
Claude d2c9593919 refactor: extract AccountRelayGroupActions from Account
Moves the ~460-line NIP-29 relay-group + Buzz workspace orchestration
(join/leave/create/delete/archive, threads, invites, pins, member/role
management, metadata edits, Buzz DMs/jobs/workflows/typing,
community member add/remove) into AccountRelayGroupActions, exposed as
account.relayGroups. External callers now use account.relayGroups.*
directly. Moved code is unchanged except for account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:11:04 +00:00
Claude 20149e2600 refactor: extract AccountMarmotActions from Account
Moves the ~540-line Marmot/MLS orchestration cluster (group create/
leave/reset, member add/remove via key-package fetch, admin grant/
revoke, metadata updates, group messaging, key-package publishing and
relay resolution) into AccountMarmotActions, exposed as account.marmot.
External callers (marmot group screens, AccountViewModel forwarders,
NotificationReplyReceiver, DecryptAndIndexProcessor) now call
account.marmot.* directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:07:24 +00:00
Claude c0932e0330 refactor: extract AccountConcordActions from Account
Moves the ~1,000-line Concord orchestration cluster (join/create/invite
flows, channel messages/reactions/edits/typing, roles and moderation,
refound/rekey/stranded-recovery, metadata + channel management,
control-plane sync) into AccountConcordActions, exposed as
account.concord. The two Concord file-level constants move with it.

Rumor ingestion (consumeConcordRumorGated, refreshConcordChannelIndex)
stays on Account since ConcordSessionManager is constructed with it,
as do the cross-feature sendMinichatReply and the read-path
isConcordBanned policy. External callers (Concord screens,
AccountViewModel forwarders, note action menus) now call
account.concord.* directly - no delegating shims.

Moved code is unchanged except for account. qualification.
Account.kt: 6228 -> 4935 lines so far in this series.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 17:05:00 +00:00
Vitor PamplonaandGitHub dc1f0dd995 Merge pull request #3842 from vitorpamplona/claude/liveventstore-searchextensions-3ya4ir
Add StoreQueryContext for observer-relative ranking in stores
2026-08-01 13:03:08 -04:00
Vitor PamplonaandClaude Opus 5 7d91931b75 rtt-open is the transport's handshake, not our own queueing
It was measured from onConnecting to onConnected, which includes the time
the call sat in the client's dispatcher queue. Under a 16,507-relay
fan-out that queue dominates everything else: published records showed a
median rtt-open of 33.5 SECONDS and a max of 90, against a true minimum
of 140ms.

That is the field aggregators rank relays by, so it was worse than
publishing nothing — a signed claim that healthy relays are slow, when
the slowness was ours.

pingMillis already carried the right number and was being handed to the
listener unused: BasicOkHttpWebSocket computes it as
receivedResponseAtMillis - sentRequestAtMillis, so it starts when the
upgrade request actually goes out and excludes everything before it.

Zero or negative means the transport could not time the handshake, and
then no timing is published rather than a fabricated one — the same rule
the rest of this class already followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:59:22 -04:00
Claude f2e7a07a97 fix(relay): make the per-connection auth set a copy-on-write snapshot
Audit finding on the StoreQueryContext seam: RelaySession kept
authenticatedUsers as a plain mutable LinkedHashSet and handed out a
live view through RequestContext. A store (or EventSource) reading the
set during a long REQ replay — exactly what StoreQueryContext invites —
could race a concurrent NIP-42 AUTH commit on another coroutine:
iteration vs. add on an unsynchronized set is a
ConcurrentModificationException or a torn read.

The engine now swaps an immutable Set behind a @Volatile field on each
AUTH (the only writer), so every read is a consistent snapshot and
holding one across a replay is safe. AUTH is rare; the copy is off the
hot path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:58:36 +00:00
Claude 7936cab9d5 refactor: extract EventBroadcaster from Account
Moves the sign-and-publish choke point out of Account into an
EventBroadcaster class: relay-set computation (outbox model, hints,
channel home relays, broadcast lists, DM inboxes, the recursive
linked-event descent) plus every publish path (sendAutomatic,
sendMyPublicAndPrivateOutbox, sendLiterallyEverywhere, broadcast,
signAndSendPrivately*, signAndComputeBroadcast,
signAnonymouslyAndBroadcast, republishEventsTo).

Account keeps one-line delegates so its 85+ internal call sites and all
external callers are unchanged; upcoming Account*Actions extractions
will call the broadcaster directly. Moved code is unchanged except for
account. qualification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:54:52 +00:00
Claude 5311efe65e refactor: extract CachePruner and CacheSearch from LocalCache; move Dao out of ui
Two read/reclaim policy clusters leave the LocalCache god object into
sibling classes in the same package, each taking the cache as its only
constructor dependency so the policies are testable in isolation:

- CachePruner: cleanMemory/cleanObservers, the six prune passes
  (hidden/old/expired/superseded/replies+reactions), and the shared
  unlinkAndRemove removal primitive (with removeIfWrap and
  editedTargetIdOf). LocalCache.deleteNote and
  DecryptAndIndexProcessor now call pruner.unlinkAndRemove;
  MemoryTrimmingService drives cache.pruner.*.
  refreshDeletedNoteObservers becomes internal so the pruner can
  notify observers.

- CacheSearch: findUsersStartingWith(username, account),
  findNotesStartingWith, and the three channel prefix searches, plus
  their private exclusion rules. Callers (SearchBarViewModel,
  AgentAttestationScreen, UserSuggestionState, BuzzNewDmViewModel) use
  cache.search.* directly - no delegating shims left behind.

Also moves the Dao interface out of ui/actions/NewMessageTagger.kt into
the model package where its implementor (LocalCache) and its types
live, removing a model-layer interface defined in a UI file.

All moved code is unchanged except for cache. qualification; behavior
is identical. LocalCache.kt: 4554 -> 3921 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:48:21 +00:00
Claude 2e90cc24ba feat(quartz): move NIP-50 extension handling into the stores; add IEventStore seams
The IEventStore contract now defines the seams a store implementation
needs instead of having the relay layer decide for it:

- NIP-50 search extensions: LiveEventStore no longer strips key:value
  tokens before the store sees them. Filter.search reaches every store
  verbatim, and each implementation decides which extensions it
  supports — the store, not the middleware, is the only component that
  knows whether sort:rank is a directive or noise. The built-in SQLite
  and filesystem stores strip at their own boundary (their FTS engines
  would otherwise error on / literally match the tokens), preserving
  the NIP-50 'ignore unsupported extensions' behavior end to end.
  Extension-aware stores need no side channel to recover the raw
  string anymore. Fs delete now checks emptiness on the stripped
  filter so an extensions-only search cannot wipe the store.

- Caller identity: new StoreQueryContext coroutine-context element,
  installed by LiveEventStore around every REQ/COUNT store call when
  the connection has NIP-42-authenticated pubkeys. Observer-relative
  stores (web-of-trust ranking, for-you relevance) read it off the
  coroutine context; ranking context only, never match-set changes.

- Negentropy liveness: snapshotIdsForNegentropy gains an optional
  onProgress hook so mirrors syncing huge corpora get a running count
  through the interface type instead of a concrete-class overload.
  SQLite reports from its row loop; the interface default streams and
  reports too.

- FtsReindexProgress.cursor documented as opaque and store-defined so
  resumable-reindex callers never assume id semantics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011hH4RY2AUwfMZ54RkMiT45
2026-08-01 16:44:31 +00:00
Claude 532b9e67fe refactor: collapse LocalCache event dispatch into grouped when branches
justConsumeInnerInner was one when(event) with ~290 branches, of which
172 were identical single-call bodies routing to consumeBaseReplaceable
or consumeRegularEvent, and ~55 more were single-line Buzz consumer
calls. Since all four shared consumers take a plain Event, the
boilerplate branches are now comma-grouped into one branch per
consumer (replaceable/addressable, regular, Buzz timeline, Buzz
store-only), keeping every branch with per-kind logic exactly as it
was.

Dispatch is provably unchanged: none of the 289 event classes has a
supertype among the classes in any other branch group, so reordering
cannot shadow a branch, and the old and new type-to-consumer mappings
were compared exhaustively and are identical. The else branch still
rejects unlisted kinds, preserving the supported-kinds allowlist.

LocalCache.kt: 5155 -> 4554 lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sJgKJ4FAjcZqvkMc7U3EA
2026-08-01 16:29:08 +00:00
Vitor PamplonaandGitHub 3823eae11d Merge pull request #3840 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 09:54:16 -04:00
vitorpamplonaandgithub-actions[bot] 9cb17a26e8 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 13:51:50 +00:00
Vitor PamplonaandGitHub 6d518adddb Merge pull request #3841 from vitorpamplona/feat/observer-out-of-band
Let a monitor publish what it learned without dialling
2026-08-01 09:49:03 -04:00
Vitor PamplonaandClaude Opus 5 c3c20c6615 Let a monitor publish what it learned without dialling
RelayObserver is a RelayConnectionListener, so on its own it can only
report on relays something opened a websocket to. On a large fan-out that
is a small minority, and it is the wrong minority: the cheap checks that
decide NOT to dial — a TCP probe, a DNS failure, a host struck out after
repeated silence — are precisely the ones that learn a relay is gone, and
their findings had nowhere to go.

Measured on a 16,507-relay list: 104 records published. Everything else
was ruled out before the client ever saw it, so the monitor had nothing
to say about 99% of the relays it had just formed an opinion on.

record() takes those findings. Same rules as the connection path — a
relay that answered is not demoted by one failed probe, and a reachable
relay with no measured time is published with no time rather than a
fabricated zero.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 09:42:00 -04:00
David KasparandGitHub da009f36bd Merge pull request #3839 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-08-01 12:07:57 +02:00
davotoulaandgithub-actions[bot] f7959571a7 chore: sync Crowdin translations and seed translator npub placeholders 2026-08-01 09:31:43 +00:00
davotoula 529c114802 fix: hoist the playback-error test fixtures out of composition 2026-08-01 11:21:47 +02:00
Vitor PamplonaandGitHub 2741d32cfd Merge pull request #3838 from vitorpamplona/claude/nip42-auth-dm-delivery-test-fhn7fd
Fix InProcessWebSocket race condition in connect-time AUTH delivery
2026-08-01 01:29:24 -04:00
Claude ae61e69136 fix(relays): deliver in-process server frames only after onOpen
Nip42AuthDmDeliveryTest stalled for its full 10s timeout on CI while
passing locally. The stall is a race in InProcessWebSocket.connect():
server.connect() runs the session's connect-time policies synchronously,
so FullAuthPolicy's AUTH challenge reached the client's listener before
the socket assigned its `incoming` channel and before onOpen fired —
breaking the WebSocketListener contract (no onMessage before onOpen).

RelayAuthenticator answers that challenge on its own coroutine. When the
signed AUTH reply hit send() before the connect thread reached the
`incoming` assignment, send() returned false and the reply was silently
dropped. Nothing recovers from that: the challenge is already dedup'd as
answered, and an EVENT rejected with OK-false `auth-required:` never
re-triggers auth (only a CLOSED does), so the pending gift wrap was
never resent — exactly the CI signature (10.011s, no auth activity
between the authenticator's Init and Destroy logs).

Server->client frames now go through an outbound channel drained by a
coroutine started only after onOpen, so every connect-time frame reaches
the listener with the socket fully wired. Order is preserved by the
single drainer, same as the existing inbound path.

Both new InProcessWebSocketTest cases fail deterministically without the
reorder (the challenge always outran onOpen; a reply sent from the first
onMessage was always rejected) and pass with it, on top of the full
:geode:test and :quartz:jvmTest suites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bf1Y91sfjxwi2ig4ymGTA9
2026-08-01 05:27:51 +00:00
Vitor PamplonaandGitHub 79f198c729 Merge pull request #3836 from vitorpamplona/feat/nip66-relay-monitor
NIP-66: measure relays from the traffic a client already makes
2026-07-31 23:23:23 -04:00
Vitor PamplonaandClaude Opus 5 18d229be58 Match the listener's parameter names; drop commas from test names
Native targets reject a comma inside a backticked name, so five tests
that read fine on JVM broke every Kotlin/Native build. Renamed without
them.

The override parameters now match RelayConnectionListener — pingMillis,
compressed, cmdStr, cmd, msg, errorMessage — which silences six warnings
and, more to the point, fixes a misreading: onConnected's second and
third parameters are the connection's ping and whether it is compressed,
and I had them named attempt and success.

Both were missed the same way: jvmTest passes without ever compiling the
native TEST sources. All five targets now compile, main and test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:55:36 -04:00
Vitor PamplonaandGitHub d6333989a4 Merge pull request #3837 from vitorpamplona/claude/quartz-hex-encode-decode-xggbv2
Add optimized decode64/decode128 and encode64/encode128 to Hex
2026-07-31 22:51:50 -04:00
Vitor PamplonaandClaude Opus 5 cf75272202 Fix the native build: toSortedMap is java.util
commonMain, so it compiled on JVM and broke every native target. Sorted
into a LinkedHashMap instead, which is the same output everywhere.

Found by CI on iosSimulatorArm64 because I had only compiled the JVM
target locally; all five now build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:31:39 -04:00
Vitor PamplonaandClaude Opus 5 5c24b003e7 NIP-66: measure relays from the traffic a client already makes
A monitor normally probes — opens connections purely to measure, then
throws them away. A client that is already subscribing, fetching and
publishing has better data for free: measured under real load, against
the relays it actually uses, at the concurrency it actually runs.

RelayObserver is a RelayConnectionListener, so it sees every connection
whichever code path opened it and none of them has to report anything:

  rtt-open      onConnecting to onConnected
  rtt-read      first REQ to its EOSE
  rtt-write     first EVENT to its OK
  reachable     it opened, or served something
  auth-required it sent AUTH, or CLOSED saying so
  the error, verbatim, when it never opened

Everything is OBSERVED. Nothing is copied from a relay's NIP-11: that is
the relay's own claim, available to anyone who asks, and republishing it
under a monitor's signature adds nothing but a chance to go stale. Where
the two disagree — a relay advertising open reads that then challenges
us — the observation is the half worth having, and copying the claim
would erase it. It also keeps quartz free of an HTTP dependency.

RelayMonitor is the whole wiring: construct one and connections are
measured, signed as 30166s on an interval, and folded into a cheap
in-memory isKnownDead for picking relays. That read has to be cheap — an
outbox picker runs per event — so it answers from a snapshot refreshed on
an interval, never a store query.

The signer is required. Measuring relay quality and letting others check
it IS NIP-66, and an optional signer would just add the failure mode this
library keeps designing out: configured, silent, doing nothing. A client
that should not publish does not construct one.

RelayObserver also replaces the CLI's RelayDiagnostics, which was the
same listener minus the timings. Porting it surfaced a bug both shared:
substringBefore(':') returns the WHOLE string when there is no colon, so
a relay's free-form CLOSED prose became its own tally key and the map
grew with the number of distinct sentences relays wrote. The colon is
now required.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:21:00 -04:00
Claude 729fb1bc17 perf(quartz): hoist lookup tables in Hex.decode/encode/isEqual/readLong; bench new codecs
javap showed the hexToByte/byteToHex field re-loaded on every use inside
these methods (16 times per readLong call) — the JVM/ART doesn't reliably
prove the load loop-invariant. Hoisting it into a local measured ~25%
faster for decode and ~10% for isEqual and readLong on the JVM
(4096 random 32-byte ids, best-of-150 rounds, 3 repeats); encode was
neutral on HotSpot but is hoisted too since ART is historically worse
at this (see the internalIsHex comment).

Branchless variants of isHex/isHex64 were also measured and were a
wash-to-slightly-worse than the branchy early-exit versions on valid
input, so those keep their current implementations.

Also adds the new exact-size codecs to the on-device HexBenchmark
(decode64, decode64OrNull, encode64, decode128, encode128, toLong256,
and the old isHex64+decode two-pass for comparison) so ART numbers can
be collected with the existing benchmark harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 01:47:01 +00:00
Vitor PamplonaandGitHub 77ff9e9699 Merge pull request #3834 from vitorpamplona/dependabot/github_actions/actions-08295fc4ea
chore(actions): bump the actions group with 5 updates
2026-07-31 21:34:08 -04:00
dependabot[bot]andGitHub 166ebc755e chore(actions): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-java](https://github.com/actions/setup-java) | `5` | `5.6.0` |
| [softprops/action-gh-release](https://github.com/softprops/action-gh-release) | `3.0.1` | `3.0.2` |
| [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) | `3` | `4` |
| [docker/login-action](https://github.com/docker/login-action) | `3` | `4` |
| [docker/build-push-action](https://github.com/docker/build-push-action) | `6` | `7` |


Updates `actions/setup-java` from 5 to 5.6.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/v5...v5.6.0)

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

Updates `docker/setup-buildx-action` from 3 to 4
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v3...v4)

Updates `docker/login-action` from 3 to 4
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v3...v4)

Updates `docker/build-push-action` from 6 to 7
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/setup-java
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: softprops/action-gh-release
  dependency-version: 3.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: docker/setup-buildx-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/login-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
- dependency-name: docker/build-push-action
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-01 00:45:45 +00:00
Claude d8bae8c628 perf(quartz): tune Hex.decodeExactOrNull at the bytecode level
javap on the previous version showed the hexToByte field re-loaded
twice per iteration, the trip count as a runtime parameter, and the
whole loop wrapped in an exception table (only needed because chars
above 0xFF overflow the 256-entry lookup table).

Now the table is hoisted into a local, the function is inline so the
32/64-byte length becomes a compile-time constant at each call site,
and out-of-range chars are rejected branchlessly: the index is masked
with 'and 0xFF' so it cannot overflow, while '255 - code' goes negative
for any char above 0xFF and is folded into the same sign-bit
accumulator that already catches invalid hex digits. No try/catch, no
exception table, no branches in the loop.

Measured on the JVM (4096 random ids, best-of-200 rounds, two runs
with variant order reversed to rule out JIT profile artifacts):
~25% faster than the previous version and ~2x faster than the
isHex64 + decode two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:33:51 +00:00
Claude 9ea4b81c1f feat(quartz): size-enforcing Hex.decode64/128 and encode64/128
Adds exact-size codec entry points to the Hex utility so 32-byte
pubkeys/event ids (64 chars) and 64-byte signatures (128 chars) with the
wrong size or invalid characters are rejected instead of silently
decoded:

- decode64 / decode128 throw IllegalArgumentException; the OrNull
  variants return null for untrusted input.
- encode64 / encode128 require exactly 32 / 64 input bytes.

The decode is single-pass: character validation is folded into the
decode loop via a sign-bit OR-accumulator (the lookup table yields -1
for invalid chars), so it is faster than the isHex64 + decode
two-pass combination.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hwv6XwT9mwGUQc57zH4ky4
2026-08-01 00:14:17 +00:00
Vitor PamplonaandGitHub 5a433663fe Merge pull request #3831 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 20:00:11 -04:00
vitorpamplonaandgithub-actions[bot] 0b77de879d chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 23:55:51 +00:00
Vitor PamplonaandGitHub 50e7f43b9a Merge pull request #3832 from vitorpamplona/feat/filter-purpose-explainer
feat(relays): explain why every subscription exists, and fix what that exposed
2026-07-31 19:52:39 -04:00
Vitor PamplonaandClaude Opus 5 9fe24be6d6 perf(relays): keep the EOSE cursor lock-free on the per-event path
The previous commit guarded EOSERelayList with a lock, which put the wrong path
under it. addOrUpdate runs on **every live event** — SubscriptionListener.onEvent
calls newEose for each one, hundreds per second across a few hundred relays — so
that was one monitor for every event arriving in the app.

Almost none of those events change the map. MutableTime exists precisely so a
known relay only bumps a Long inside its own entry; the map is structurally
written on the *first* frame from a relay, plus remove() and clear(). A couple of
hundred writes for the lifetime of the process, against millions of reads.

So the map is copy-on-write behind @Volatile: replaced wholesale under the lock,
never mutated in place after publication. Readers take nothing. The per-event
bump takes nothing. Only a relay's first frame pays, and it pays a map copy of a
few hundred entries, once.

The bump itself stays unsynchronized, which is deliberate: two socket threads
racing updateIfNewer can leave the older timestamp, and this value is a floor for
`since`, so losing a millisecond re-asks for a couple of events rather than
skipping any. Documented on the method rather than fixed with an atomic that
would cost a barrier per event.

Verified: iOS, JVM, Android, desktop and the full suite build and pass. Cold start
on emulator-5554 — no fatal exceptions, no ConcurrentModificationException, 0
nos.lol refusals, 182 relays connected and the purpose breakdown populated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:47:50 -04:00
Vitor PamplonaandClaude Opus 5 5fe33a8152 fix(relays): serialize writes to the single-sub EOSE cursor map
EOSERelayList backs SingleSubEoseManager with a plain mutableMapOf, and
addOrUpdate is called from SubscriptionListener callbacks — i.e. from each
relay's own socket-reader thread. A client holding a few hundred relays therefore
had that many concurrent writers to one unsynchronized map. EOSEAccountFast wraps
its lists in a lock for exactly this reason; a bare list handed to
SingleSubEoseManager had nothing.

The race predates this branch but the branch made it load-bearing: both merged
managers (notifications and account metadata, across every logged-in account and
every relay they read) now run through SingleSubEoseManager, and the EOSE refetch
fix added a second writer in remove(). Writes are serialized with KmpLock, the
same primitive ComposeSubscriptionManager uses.

Reads still go through the live map from since(), deliberately — but the two
merged managers no longer depend on that. They were reading `since` immediately
after clearing a relay and relying on the mutation being visible through it, so
hardening since() into a snapshot later would have silently disabled the refetch
with no test to notice. Growth now zeroes the cursor for that pass explicitly, in
addition to clearing it.

Verified: both iOS targets, JVM, Android, desktop and the full suite build and
pass; a cold start on emulator-5554 shows no fatal exceptions, no
ConcurrentModificationException, and 0 nos.lol refusals.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:36:28 -04:00
Vitor PamplonaandClaude Opus 5 b8d0caa8ff fix(commons): import kotlinx.coroutines.IO so the iOS targets build
`Dispatchers.IO` is an internal member in common code; the public form on
Kotlin/Native is the `kotlinx.coroutines.IO` extension property. Without that
import the member resolves on JVM and Android and fails only on iOS, which is
precisely what commons' compile-only iOS spike exists to surface.

MergedTopFeedAuthorListsState moved into commonMain in 3f4723437e and was the one
file that came across without the import — the other 22 commonMain users of
Dispatchers.IO already have it, and the only other three files that mention it do
so in comments. One error, one file, one missing import.

Reproduced locally with :commons:compileKotlinIosSimulatorArm64 before fixing;
both iOS targets, JVM, Android and the full test suite pass after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 19:25:26 -04:00
Vitor PamplonaandClaude Opus 5 e3e17e8ecb fix(notifications): stop resetting the watchdog alarm on every account change
The service layers were being re-enabled level-triggered. Before this branch the
inner flow was a Boolean behind distinctUntilChanged, so enableServiceLayers()
ran once per real transition. It now carries (all accounts, participating
accounts), and Account has no equals — so the flow re-emits whenever the account
map changes identity, and the outer collector restarts on every
foreground/background crossing, which includes every screen-off.

That matters because ServiceWatchdogManager.schedule() calls setInexactRepeating
with FLAG_UPDATE_CURRENT and a first fire of `elapsedRealtime() + 5min`. Every
call replaces the alarm and pushes that first fire out again. Screen-on happens
far more often than every five minutes, so L5 — the layer whose entire job is
noticing a dead service and restarting it — would effectively never have fired.

NotificationCatchUpWorker was unaffected: it enqueues with
ExistingPeriodicWorkPolicy.KEEP, so repeat calls leave the existing period alone.

Enable/disable is edge-triggered again, explicitly this time rather than as a
side effect of what the upstream flow happens to emit.

Also drops ActiveSubscriptionsState.busiestPurposeFilters, which has no readers —
the cards draw their share against attributedFilters.

Verified on emulator-5554: after a cold start, HOME, and return, the foreground
service is still running and the watchdog alarm is still registered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:54:46 -04:00
Vitor PamplonaandClaude Opus 5 a295f31f94 refactor(relays): mount the Cashu wallet like every other account subscription
The wallet was the only account-level subscription whose lifetime was decided by
the model. CashuWalletState collected relay sets on the Account's own scope and
called subscribe/unsubscribe itself, so it ran for every Account object that
happened to be resident — including accounts loaded purely so pushed gift wraps
could be decrypted by their owner, which have no wallet anyone is looking at.

Gating that with a subscribedAccounts flow made it worse: a model object read the
relay layer's bookkeeping ("is this pubkey REQ-ing anywhere?") to decide whether
to talk to relays, and encoded a proxy for the rule rather than the rule. It
happened to work only because the registry mounts every account in the
foreground.

CashuWalletEoseManager is a PerUserEoseManager in the account group, exactly like
NwcNotificationsEoseManager already was. Start and stop now come from the same
mounts as notifications, DMs and gift wraps — the screen's for the account on
show, the registry's for the rest — and will follow whatever the
foreground/background rule becomes without knowing about it. Per user, never
merged: each wallet reads its own outbox for its own events and its own inbox for
nutzaps addressed to it.

commons keeps the query shape as a plain cashuWalletFilters() function and loses
the CashuWalletFilterAssembler wrapper. subscribedAccounts disappears entirely —
from Account, AccountCacheState, AppModules, both AccountViewModel previews, both
androidTests, and AccountFilterAssembler itself.

Verified on the wire, not just on the screen: with REQ logging temporarily on,
134 REQ frames carried kind 17375 across 9 relays naming all 4 logged-in accounts,
and 90 carried kind 9321. The subscriptions screen agrees — Wallet and Nutzap
Inbox under each of the four.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:42:25 -04:00
Vitor PamplonaandGitHub 7e9abd86b3 Merge pull request #3833 from vitorpamplona/claude/crypto-security-review-f13uy2
Fix SharedKeyCache hash collision vulnerability & add constant-time MAC comparison
2026-07-31 18:13:57 -04:00
Vitor PamplonaandClaude Opus 5 19a0088e36 fix(relays): refetch when a merged subscription gains an account
A merged filter keeps one EOSE cursor per relay. That is right while the set of
accounts it covers is stable and silently wrong the moment it grows: the account
that joins inherits a cursor it never earned, so `since` skips everything older
and its history is never requested. The subscription looks healthy, reports EOSE
and delivers new events forever after — it just never backfills.

This is the normal startup path, not an edge case. The account on screen mounts
from Compose and EOSEs within a second or two; the registry brings the rest in a
moment later, onto relays that have already reported EOSE. Account switching and
mid-session login hit it the same way.

It bites metadata harder than notifications. Notifications have a backward pager
that is deliberately still per-account and can recover the gap. Metadata has
none, so an account that joined mid-session would go without its own profile,
follows and lists until the next launch cleared the in-memory cursor.

MergedAuthorTracker records which accounts each relay's filter last covered and
reports growth; both merged managers drop that relay's cursor when it grows, so
the next filter asks from scratch. Growth only: an account leaving takes nothing
with it, and the accounts that remain already have their events.

Verified: MergedAuthorTrackerTest covers first-sight, join, no-op, leave, swap,
per-relay independence and clear — mutation-checked by making the predicate
return false, which fails three of the seven. On emulator-5554 all four accounts
keep their Notifications, DM Inbox and Account's data, and nos.lol refusals over
three clean cold starts were 0/0/7 against 8 before, so the extra refetch costs
nothing measurable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:56:19 -04:00
Vitor PamplonaandClaude Opus 5 903bf11abf refactor(relays): audit cleanups on the subscription work
Three findings from reading the branch end to end:

- AccountNotificationsEoseFromInboxRelaysManager carried a `pubkeyOf` helper with
  no callers and a comment inventing a reason for it. Removed.
- `limit = 1 * pubkeys.size` said the multiplication out loud for no reason.
- The two merged managers scale their limits differently and nothing said why.
  Metadata multiplies by account count, notifications does not, and that is
  deliberate: metadata is replaceable so its limit is a safety bound, while
  notifications are a stream whose limit is a page size — scaling would ask 4x
  the data on every cold start and relays clamp it anyway. Documented, along with
  the consequence (a busy account can crowd a quiet one out of the shared
  newest-N) and what recovers it (the history pager, left unmerged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:12:36 -04:00
Vitor PamplonaandClaude Opus 5 7fb99842a8 fix(relays): FOLLOW_LISTS does not run in the background
Every producer of FOLLOW_LISTS is a browse filter on the Follow Sets feed —
FilterFollowSetsBy{Authors,Community,AllCommunities,Geohash,Hashtag} and Global,
all under discover/nip51FollowSets. Nothing account-level assigns it: the
account's own follows ride ACCOUNT_DATA with the rest of its lists. So the
`runsInBackground = true` described a producer that does not exist.

The flag has no code consumer — isWorthNamingInNotification keys off `group`, not
this. It is read by whoever is looking at a backgrounded breakdown and asking
which of these purposes is allowed to be there, and the enum says a
`runsInBackground = false` purpose appearing while backgrounded is a leak. A
browse feed claiming to be allowed is exactly the entry that would get waved
through.

Nothing behaves differently. It stops the taxonomy lying to the next person who
uses it to diagnose one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:59:04 -04:00
Claude a59bc6d82d refactor(nip44): use a content-addressed key object for the shared-key cache
Replaces the hex-string cache key with a small CacheKey holding the raw
(privateKey, pubKey) references — no byte copy and no ~400-byte hex String per
lookup. The precomputed Int hash is only a bucket selector; equals() does the
authoritative full-content comparison, so collisions share a bucket and are
disambiguated rather than returning the wrong peer's secret. Same value-type
immutability contract already relied on by X25519KeyPair as a map key.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:13:59 +00:00
Claude e94b8eaf3b fix(nip44): content-address shared-key cache and use constant-time MAC checks
Two hardening fixes in the NIP-44/NIP-04 encryption primitives:

1. SharedKeyCache keyed conversation/shared secrets by a 32-bit polynomial
   hashCode of (privateKey || pubKey). Distinct peers whose bytes hash to the
   same Int collided on one LRU slot, so get() could return one peer's key for
   a message meant for another — a silent wrong-key encrypt/decrypt. The hash
   collides independently of the private key, so a pubkey aliasing a victim's
   contact is grindable. Now keyed on the full (priv || pub) byte content.
   Added SharedKeyCacheTest, which fails on the old hashCode key and passes now.

2. NIP-44 MAC verification (Hkdf.fastExpand and Nip44v2.checkHMacAad) used
   ByteArray.contentEquals, which short-circuits on the first mismatching byte
   and leaks a timing side channel on the HMAC tag. Switched to a shared
   equalsConstantTime helper (utils/ConstantTime.kt), matching the constant-time
   tag checks already used in ChaCha20Poly1305/XChaCha20Poly1305/MLS.

Also corrects the X25519 KDoc, which claimed the JVM/Android actual delegates to
java.security XDH; it is in fact the same pure-Kotlin Montgomery ladder as the
other targets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PY7kpiTcFiDshYqBsQ5KVC
2026-07-31 20:00:07 +00:00
Vitor PamplonaandClaude Opus 5 d7d1b052fe fix(relays): file the account's own contact cards under account data
"Observing Profiles" showed up for accounts that had no screen at all, which is
the one thing that purpose is explained as never being: "profiles of the people
currently on screen".

ContactCardFilters has two builders sharing SubPurpose.PROFILE_METADATA, and only
one of them observes anything. filterContactCardsToTargetKeysFromTrustedAccounts…
fetches kind:30382 cards ABOUT the users on screen — that one is right.
filterContactCardsByAuthorInTheRelay fetches the account's OWN nicknames in the
login-time bulk download, from the account's own relays, with nobody on screen;
its own KDoc said as much while the purpose said otherwise.

It rides filterAccountInfoAndListsFromKey, so every logged-in account carried one
of these per relay — Dr Martha Liz's two relays showed as "Observing Profiles ·
2 filters · 2 relays" for an account nobody was looking at. It is account data,
and now says so.

Verified on emulator-5554: only the account on screen still reports Observing
Profiles; the other three carry Account's data alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 15:54:29 -04:00
davotoulaandClaude Opus 5 d9e01c8504 i18n: translate missing amethyst strings for cs, de, sv and pt-BR
Fills the on-disk gaps in the four target locales: channel archive/unarchive,
the new-forum title, the External Content screen title, the archived channel
section header, the three share targets (picture / short / video) and the URL
preview's open-in-browser action.

Wording follows each locale's existing siblings rather than being translated
fresh -- url_preview_open_in_browser reuses the string already in
git_repo_open_in_browser, share_target_as_picture follows new_picture, and
share_target_as_short_video follows home_content_type_shorts.
relay_group_section_archived heads a channel list, so cs/sv/pt take the plural
adjective.

Keys whose translation would be byte-identical to the English source are left
out on purpose: Crowdin strips source-identical entries on export and Android
already falls back to values/strings.xml at runtime. That covers the "workflow"
loanword in cs/de, and Highlights / Podcasts / Reposts / Torrents / Videos /
Name / Backlog in de, plus Podcasts / Torrents / Backlog in pt-BR.

The commons composeResources tree was already complete for all four locales.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WopdqNoZ9tYoMNppJG17BL
2026-07-31 21:42:30 +02:00
Vitor PamplonaandGitHub 5231f5b051 Merge pull request #3826 from vitorpamplona/l10n_crowdin_translations
New Crowdin Translations
2026-07-31 14:05:43 -04:00
Vitor PamplonaandClaude Opus 5 e1404f4d6c fix(relays): drop "Your" from the account data label
The row sits under an account header that already names whose it is, and with
several accounts on screen "Your Account's data" repeated under someone else's
name reads as a contradiction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:05:26 -04:00
Vitor PamplonaandClaude Opus 5 15f8b7713e fix(relays): re-attribute account data after the metadata merge
"Your Account's data" fell into "Not attributed" one commit ago. None of the
metadata builders ever named their own account — they relied on
PerUserEoseManager stamping `attributedTo(pk)` over whatever they returned. Moving
the manager to SingleSubEoseManager took that away, because that base class
cannot attribute: one of its filters may serve several accounts, so there is no
single pubkey to stamp.

So the builders name themselves now, which is where the knowledge actually is.
Every filter in this package fetches an account's OWN profile, lists, bookmarks
and recent posts, so `authors` and the accounts asking are the same list.

The one exception is filterBasicAccountInfoFromKeys, which fetches OTHER people's
profiles for the account-switcher avatars: there the authors are precisely not
the requester, so it takes `requestedBy` and is attributed to that instead. Its
parameter carries the reason, since it reads like an inconsistency otherwise.

Notifications were unaffected — those builders already set accountPubKeys
themselves, which is why only this purpose lost its account.

Verified on emulator-5554: "Your Account's data" appears under all four accounts
again, and the unattributed count is back to 8 filters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 13:48:23 -04:00
Vitor PamplonaandClaude Opus 5 fe2d7ef045 feat(relays): merge account metadata across accounts into one REQ
Account metadata was the largest single contributor to blowing a relay's
subscription cap: seven filters in a subscription, repeated once per logged-in
account. Every one of them is `authors`-keyed, so a relay that several accounts
read from can be asked about all of them by widening `authors` — the same shape
of merge the notification tail just took, and for the same reason.

The per-account `limit`s are summed rather than shared. These are mostly
replaceable events, so the limit is a safety bound rather than a page size, and
scaling it by the number of accounts leaves each one exactly the headroom it had
alone. That is the difference from gift wraps, which stay per-account because
their `limit` IS a page size over unsolicited content.

Measured on emulator-5554, four accounts, cold start, counting nos.lol's
`ERROR: too many concurrent REQs`: 13 before either merge, 11 after
notifications, 8 after this. The subscription count on that relay went from
24-26 to 23, against its cap of 20.

So the per-account multiplication is no longer the driver, and the remaining 23
are not account-level at all — they are the feed, channel and finder assemblers.
Cutting further means looking there, not at more merging.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:56:03 -04:00
Vitor PamplonaandClaude Opus 5 24decefd4b feat(relays): merge the notification tail across accounts into one REQ
Every account-level loader is a PerUserEoseManager, which opens one subscription
per user. With four accounts open that multiplies, and shared relays run out of
room: nos.lol (strfry, `max_subscriptions: 20`) answered `ERROR: too many
concurrent REQs` — 0 times before this branch, 7 with three accounts subscribed,
13 with four. The refusal arrives as a NOTICE, which carries no subscription id
and never reaches RelayReqRefusals (wired only to CLOSED), and "too many
concurrent REQs" matches none of its markers. So the relay drops those REQs while
we still believe they are live: they never EOSE and never deliver.

Notifications are `#p`-scoped, so a relay serving several accounts can be asked
about all of them in one filter naming every pubkey — same query, wider tag. The
manager moves to SingleSubEoseManager: one REQ per relay instead of one per
(account, relay), with the `since` floor taken per relay as the OLDEST of the
participating accounts' floors so widening for one can never cut another short.

Gift wraps deliberately do NOT merge. They are unsolicited and opaque to the
relay, so it cannot rate-limit them per recipient; a merged query would let one
spammed account eat the shared `limit` and starve every other account's DMs.
Each account keeps its own budget there.

A merged filter serves several accounts, so accountPubKey becomes accountPubKeys.
The subscriptions screen shows such a filter under each account it serves —
"why is this relay busy for me" has to be answerable per account — which makes
the per-account counts a breakdown of a shared filter rather than a partition of
the total. attributedFilters is that sum, and is what a card's share is drawn
against now; dividing by the wire total would read as 100% for each of two
accounts sharing one filter.

Measured, and it is only part of the answer: nos.lol carries 24-26 subscriptions
against its cap of 20, and this removes 3 of them. The rest are the other
per-account managers — account metadata alone is 7 filters in a subscription per
account, and gift wraps another. Merging metadata (it is `authors`-keyed and
merges the same way) is the next lever; this commit does not get us under the cap
on its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:09:20 -04:00
vitorpamplonaandgithub-actions[bot] 0f4580ab91 chore: sync Crowdin translations and seed translator npub placeholders 2026-07-31 14:58:45 +00:00
Vitor PamplonaandGitHub cdef4e9658 Merge pull request #3830 from vitorpamplona/claude/servicetype-tag-bounds-check-533b4r
Fix ServiceType parsing to handle edge cases safely
2026-07-31 10:56:02 -04:00
Claude 51e388d2a1 fix: bounds-check ServiceType parsing of NIP-85 service tags
ServiceType.parse destructured the result of split(":", limit = 2) into
two components, so any value without a colon (e.g. the "client" of a
["client", "nostria"] tag) threw IndexOutOfBoundsException instead of
returning null. It also accepted "30382:" as an empty service type.

ServiceType.isOfKind had the same class of bug: it read
serviceType[kind.length] after startsWith, which is out of bounds when
the value equals the kind exactly ("30382" vs "30382").

Parse the separator by index and check the length before indexing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112ysMEzSaezH9mXFteNt13
2026-07-31 14:54:13 +00:00
Vitor PamplonaandGitHub 9a5d47004c Merge pull request #3827 from davotoula/fix/location-foreground-gate
Listen only while foregrounded, and fix the meter that measures it
2026-07-31 10:21:07 -04:00
Vitor PamplonaandGitHub 731f4369cb Merge pull request #3828 from vitorpamplona/claude/content-parse-error-a1pf9h
Fix metadata JSON parsing to use lenient parser consistently
2026-07-31 10:14:56 -04:00
davotoula d93f4db451 docs(location): record the clean-install smoke run, closing R1 2026-07-31 15:57:04 +02:00
Vitor PamplonaandClaude Opus 5 61611bdc48 feat(subscriptions): every account pulls while a screen is up
"Keep this account active in the background" gated subscriptions everywhere,
not just in the background. An account you had not opted in for showed no
notifications and no DMs even with the app open in front of you — you had to
switch to it and wait for its subscriptions to mount from scratch. The setting's
name only ever promised something about being away.

So the rule now matches the name. While any activity is STARTED, every loaded
account pulls its own notifications, DMs and gift wraps: the user can switch
accounts at any moment and expects the one they land on to be current, and this
costs nothing once the app is away because it ends with the screen. When the app
goes away, the set narrows to the accounts that opted in — which is the only
thing the flag decides now.

The service layers stay where they were, gated on the master switch AND somebody
having opted in. A foreground-only account must never start a foreground service
that outlives the screen that wanted it, so those two questions are answered from
one snapshot of accounts + flags rather than two.

The registry loses "Background" from its name along with the assumption: it
mounts exactly the set it is handed and decides nothing, so the rule lives in one
place.

Verified on emulator-5554 with 4 loaded accounts, 1 opted in. Foreground: 4
mounted, and the account that had no section on the subscriptions screen at all
now shows Notifications 8 filters / 2 relays plus DM Inbox. On HOME: 3 mounted
(-1), releasing the one that never opted in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:56:01 -04:00
Vitor PamplonaandClaude Opus 5 f2895b2d29 fix(relays): count filters once per filter, not once per entity it names
The Active Subscriptions screen reported two different things as "filters".
The header counted filters; a purpose card summed its per-entity rows. Those
only agree when every filter names exactly one entity — and the busiest ones
name many, because batching is the whole point of them: one `#e` filter per
relay carrying every followed chat, one `#d` filter per host relay carrying
every joined group.

So six chats on six relays read as 144 filters where 24 were on the wire, and
"8% of all" divided the inflated number by the real one. The screen exists to
answer "why do I have this many subscriptions", and it was overstating its
loudest purposes by exactly their batching factor — the opposite of the job.

A purpose now tallies each filter once as it is scanned, before the fan-out.
The per-entity rows stay, because "which chats is this relay serving" is worth
seeing, but they are a breakdown rather than a total: the field is renamed to
namedInFilters and says in its docs that it is not summable.

The aggregation moves out of the ViewModel into a pure aggregateSubscriptions()
so the invariant is testable without a relay pool. AggregateSubscriptionsTest
pins it on the reported shape, and was mutation-checked: restoring the old
`entityRows.sumOf { … }` fails three of its four cases, the fourth being the
relay count, which never depended on it.

On device, Public Chat goes from 144 filters over 6 relays to 18 over the same
6, and Relay Groups from 116 to 96. No filter changed; only the arithmetic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:31:17 -04:00
davotoula 2360829e83 Code review:
- refactor(location): dedupe test fixtures and simplify the gate internals
- test(location): assert the release, not just its ledger side-effect
- test(location): cover the LocationState -> LocationFlow -> RefCountedSession seam
2026-07-31 15:19:44 +02:00
davotoula f09eef1470 feat(amethyst): wire the location foreground gate and refcounted meter 2026-07-31 10:26:28 +02:00
davotoula 78f0583b16 test(amethyst): document LocationStateTest deviations, close profile gap
feat(amethyst): gate location listening on foreground
2026-07-31 10:26:28 +02:00
davotoula 4234c4f45b docs(location): cite the proven mechanism for LocationFlow's try/finally
fix(test): close the seed-after-registration ordering gap in LocationFlow

fix(test): replace non-discriminating LocationFlow cleanup test
2026-07-31 10:26:28 +02:00
davotoula a742cec495 feat(amethyst): register one location provider with a paired listening hook
feat(amethyst): add location provider ladder
feat(amethyst): add RefCountedSession for overlapping ledger holders
2026-07-31 10:24:39 +02:00
davotoula 3fe936bedb docs 2026-07-31 10:24:16 +02:00
Vitor PamplonaandClaude Opus 5 8e019a2d59 feat(relays): explain discovery filters by the feed selection behind them
Commit 4f1bd6e0c2 left the six public-chat discovery producers untagged and
justified it as "they search for chats rather than serving known ones, so there
is no entity to name". The first half is right and the conclusion does not
follow: having no entity is not the same as having no explanation. Every one of
those filters is built from a top-nav selection — Global, your follows, a
hashtag, a geohash, a community — which was known where the filter was built and
simply had nowhere to travel. The screen could only render them as "All", which
is the one thing they are not.

3f4723437e already moved the 25 top-nav value types into commons for exactly
this, so ExplainedFilter now carries the scope. It carries the per-relay value
rather than the whole set: the filter is already scoped to one relay, so it
holds only the slice that applies to it and no reference to the other relays'
authors. It stays a typed value rather than a formatted string because
purposeDetail already taught that lesson — text built in commons can never be
translated, so the UI matches on the type and picks its own wording.

scopedTo() stamps it at each feed's make…Filter dispatch, the last place that
still knows the selection; below it the builders have flattened it into
authors/#t/#g and it is unrecoverable. IFeedTopNavPerRelayFilterSet grew
scopeFor(relay) so that stamping is compiler-enforced across all 11 sets rather
than a type-switch that silently misses the next one added.

The screen groups these rows by scope *type*, not contents: an author-based
selection sends a different slice of the follow list to every relay, so keying
on contents would shatter "People you follow" into one row per relay — the
opposite of what the screen is for.

ExplainedFilterTest had not compiled since 4d53bbea9e renamed entityId to
entityIds, because `./gradlew test` does not run :commons:jvmTest. Repaired, and
extended to pin the new field: the scope is a slice of the user's follow list or
their chosen hashtag, and handing a relay the selection rather than the authors
it already sees would tell it which of its neighbours' filters belong together.

Verified on emulator-5554: Home Feed's row now reads "People you follow" with
its 176 relays, as one row rather than 176. The public-chat discovery producers
take the identical path but only mount while the Discover→Chats screen is open,
which this device's bottom nav has no tab for, so that specific row is unproven
on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 00:20:41 -04:00
Vitor PamplonaandClaude Opus 5 548a5c979e feat(subscriptions): subscribe every background account, not just the active one
An account that opted into "keep this account active in the background" got
nothing from that setting but a running service. Subscriptions were only ever
created from a composable holding an AccountViewModel, so the account on screen
was the only one talking to relays; the rest were loaded purely so pushed gift
wraps could be decrypted by their owner. On a device with no push — no Play
Services, no UnifiedPush distributor, no Pokey — those accounts pulled nothing
at all, and the setting quietly meant less than its name.

BackgroundAccountSubscriptionRegistry mounts the always-on loaders for each
participating account directly, with no view model behind them.
AlwaysOnNotificationServiceManager already watched the two flags that define
participation (the account's own toggle, or its NIP-46 signer), so it now keeps
the set instead of collapsing it to "is anyone participating" and hands it to
the registry on every change.

Running headless meant the key had to stop assuming a screen. AccountQueryState
drops to what an Account alone can supply, and AccountUiQueryState adds the feed
states for screens — the only always-on reader is the notifications cold-start
floor, which reads a feed nothing fills without UI and so could only ever be
null for these accounts. An account can now be mounted twice, on screen and in
the background; the managers dedup by user but keep whichever key arrived first,
so preferredKeys picks the screen's key and the account being looked at keeps
its floor rather than losing it to a race.

The Cashu wallet had the mirror-image bug: it subscribed from every Account's
own scope, ungated, which put a Wallet and a Nutzap Inbox on the wire for every
saved account — including accounts that never opted in and have no wallet. It
now follows AccountFilterAssembler.subscribedAccounts, the same truth both mount
paths write to.

Verified on emulator-5554 with 4 loaded accounts, 3 participating: each of the
3 gets its own Notifications and DM Inbox (12 and 3 filters for a background-only
account), filters scale per account while relays overlap, and the 4th account —
loaded but not participating — now holds no subscriptions at all where it
previously held Wallet and Nutzap Inbox.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 23:31:58 -04:00
Vitor PamplonaandClaude Opus 5 54afbf95e7 docs(notifications): describe the cold-start floor as what it is
The comment called it a backward-paging boundary that asks for everything
older than the feed's oldest card. It is neither: backward paging lives in
AccountNotificationsHistoryEoseManager, and `since` means newer-than, so
it floors the query at the depth the feed already reaches instead of
asking all-time again. It is also read only until a relay has an EOSE.

Records that it is always null for an account with no UI, since nothing
fills that feed — which is what makes the field inert on a headless path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:58:05 -04:00
Vitor PamplonaandClaude Opus 5 3f4723437e refactor(commons): move the top-nav per-relay filter values out of the app
The topNavFeeds package holds two layers. The value layer — the per-relay
filter sets, plain `Map<relay, filter>` of authors, hashtags, geohashes —
is pure data. The resolution layer around it (TopNavFilter, FeedFlow, the
loaders and decryption caches) evaluates feeds against the live event
graph and needs LocalCache, NoteState and the outbox loaders, so it is
app-coupled by nature and stays put.

Moving the 25 value types lets commons describe a feed selection without
depending on the app, which is what an ExplainedFilter needs if it is to
carry the top-nav scope instead of a flattened List<HexKey>. Desktop gets
them too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:14:15 -04:00
Vitor PamplonaandClaude Opus 5 4f1bd6e0c2 feat(relays): name which public chats each subscription serves
Public Chat collapsed into a single unnamed row because none of its
producers carried an entity id, so the screen could say how many filters
were running but not which chats caused them.

The six channel-scoped producers now tag their channel ids — including
the batched ones, which carry every channel a relay serves so the screen
fans them into a row each.

The six discovery producers (global, by author, by community, by hashtag,
by geohash) stay untagged on purpose: they search for chats rather than
serving known ones, so there is no entity to name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:05:57 -04:00
Vitor PamplonaandClaude Opus 5 5f6d09425d feat(notifications): open the subscriptions screen from the ongoing notification
Tapping the always-on notification landed on whatever tab was last open,
which does not answer the question that notification raises. It now
deep-links to Active Relay Subscriptions via an `activesubs` route, and
the screen gets its own entry in All Settings.

The expanded breakdown also held its last value across reconnects. It is
derived from the *connected* relays, so a drop to zero — the "connecting"
state — emptied it and collapsed the expanded view to a single line
exactly when someone was most likely reading it. What each connection is
for does not change while it re-establishes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:29 -04:00
Vitor PamplonaandClaude Opus 5 0ad4726d41 feat(relays): name the things each subscription is for, not just their ids
Entity rows resolved to a short hex whenever LocalCache could not place
the id, which covered Marmot groups, Concord communities and geohash
cells — the three that most needed naming.

Each now resolves through the path its own subsystem uses: Marmot reads
the per-account chatroom StateFlows and its encrypted Blossom avatar,
Concord reads the folded Control Plane keyed on the session revision, and
geohash cells go through the app's reverse-geocode cache so a cell reads
as a place. All three route to their screen on tap.

Relay groups pivot on the host relay, expandable to the groups it carries:
a group is keyed by (id, host relay) and one community relay routinely
hosts many, so the flat list repeated one hostname seven times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:18 -04:00
Vitor PamplonaandClaude Opus 5 4d53bbea9e fix(relays): attribute every subscription to the account that asked for it
Filters carry an accountPubKey so the relay screens can group by account,
but attribution only happened for keys implementing AccountScopedQuery —
and ~50 query states held an `account` without declaring it, so the cast
failed silently and their filters showed as unattributed.

Two of the gaps were real bugs rather than display issues:

- CashuWalletFilterAssembler took `keys.first().pubkey` while flat-mapping
  every account's relays, so with two wallets logged in the second was
  never subscribed and its inbox relays were queried for the first
  account's nutzaps. Now built per account.
- UserReportsSubAssembler unioned every account's follow list into one
  per-relay map, asking one account's follows of another's outbox relays.
  Now one pass per account.

Where a subscription genuinely pools accounts (outbox discovery, on-screen
event watching, profile metadata), it is attributed only when a single
account is asking rather than inventing an owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 21:00:03 -04:00
Vitor PamplonaandClaude Opus 5 b6808959cd refactor(relays): split the public-chat purpose into the five protocols it held
PUBLIC_CHATS was a catch-all carrying NIP-28 chats, NIP-29 relay groups,
ephemeral chats, geohash chats and live-stream chat under one row labelled
with the NIP-29 name — so the subscription screen could not say where any
of them came from, and the label was wrong for four fifths of what it
counted.

Each is now its own purpose with its own label and explainer. Geohash
cells carry their id too, so location chats list one row per cell and
render as a place name instead of a single opaque group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:59:48 -04:00
Vitor PamplonaandClaude Opus 5 1e20e10497 fix(notifications): sample the straggler relays instead of watching all of them
The probe that catches mentions delivered to the wrong relay was subscribing to
every relay the user's follows post to — ~330 relays, ~670 filters held
permanently, by a wide margin the largest thing the client ran. Despite the class
name nothing about it was random and nothing bounded it; `updateFilter` returned
`followsPerRelay.keys - notificationRelays` in full, and a grep for
take/shuffled/random/subList across the manager found nothing.

It now watches a rotating window of 5 relays that slides every 5 minutes, so the
whole space is still swept, just never all at once. That matches what the job
actually needs: a background sweep for misdelivered mentions, while the inbox
relays carry the real traffic.

The window is a contiguous slice of a URL-sorted list, not a fresh random draw.
Re-picking at random on each invalidation would re-REQ a different set every few
seconds — the manager already invalidates on follows changes (debounced 5s) and
notification-feed updates (sampled 5s) — which would cost more than the
subscriptions it replaced. Deterministic order means the window only moves when
the rotation timer says so. The rotation job is registered alongside the existing
two and cancelled with them in endSub.

Measured on device, cold start, same account:

    before   NOTIFICATIONS  670 filters  168 relays
    after    NOTIFICATIONS   58 filters   13 relays     (8 inbox + 5 sampled)

The Active Subscriptions explainer was updated in the same commit: it described
the old behaviour, and an explainer that lies is worse than none.

HOME_FEED is now the largest at 2,485 filters across 355 relays. That is the
outbox model working as designed rather than an anomaly, but it is the next thing
worth looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:33:48 -04:00
Vitor PamplonaandClaude Opus 5 a9ced24eb9 feat(relays): add an Active Subscriptions screen
Replaces the per-relay purpose chips with a screen whose only job is to answer
"why do I have this many subscriptions right now".

The chips were the wrong shape. Pivoting on relay hides the thing worth finding:
the notifications straggler probe holds ~670 filters across 168 relays, and on a
relay-shaped list that is one unremarkable chip repeated on 168 rows. Pivoted on
purpose it is a single line that dwarfs everything under it, which is exactly how
it was spotted in the first place.

Account is the outer grouping — several accounts are normally logged in, they do
not share relay sets, and a mixed total cannot be acted on. Purposes sort by
filter count, expand to per-entity rows, and carry an explainer describing the
actual strategy rather than the intent. Those explainers are written from the
code they describe: MODERATION says it asks each relay your follows publish to
because UserReportsSubAssembler walks declaredFollowsPerOutboxRelay, and
NOTIFICATIONS mentions the follows-wide probe because
AccountNotificationsEoseFromRandomRelaysManager subscribes to every follows relay
with no sampling.

Names resolve at render time from LocalCache and fall back to a short id — a name
captured when the filter was built would usually be missing (profiles arrive
later) and would go stale on rename.

Untagged filters are counted and shown rather than hidden. A total that claims to
be fully attributed when it is not would defeat the point of the screen.

Reached from the Connected Relays list, which is where the question occurs to
people. Notification filters now carry accountPubKey so the largest purpose
groups correctly; the remaining assemblers still report under "Not attributed to
an account" until they are threaded through.

Counts use two separate plurals composed at the call site rather than one string
with two %d, so "filter" and "relay" decline independently.

NOT visually verified: reaching the screen needs drawer navigation that was not
worth scripting. Compile-clean, tests green, and it reads the same
activeRequests data already verified on device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:25:40 -04:00
Vitor PamplonaandClaude Opus 5 e47a2376aa feat(relays): carry entity and account on tagged filters
Adds entityId and accountPubKey to ExplainedFilter so a chip can answer "which
community made me connect here, and for whose account" rather than a bare
"Communities". Both ride copy() and are covered by the existing
never-reaches-the-wire test.

Ids, not names: names change, are often not loaded when the filter is built, and
would pin a stale copy into a subscription that outlives them. The UI resolves
against LocalCache at render time.

A pubkey, not an Account reference: filters are held by the relay pool for the
session and outlive the objects that built them, so a reference would keep a
logged-out account alive. Several accounts are normally active and do not share
relay sets, so without this one account's communities read as another's.

purposeEntities() returns the (purpose, entity, account) rows a relay is serving,
which is what a per-relay or per-subscription screen needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 11:13:10 -04:00
Vitor PamplonaandClaude Opus 5 397246b2ca feat(relays): show what each relay is doing, in the app's own words
Adds the purpose chips to the Connected Relays rows, and retires the invented
vocabulary the notification was using.

"Relay lists" and "Moderation" read as fuzzy because they were words I made up
that match nothing the user can navigate to. Every label now points at a string
the app already shows somewhere else — nav routes (Home, Discover, Messages,
Notifications, Chess), event-kind names (Reports, Profile, Follow List, Outbox
Relays, Drafts, Reactions) and feature names (Communities, Nests, Marmot Group,
Wallet). Twelve invented strings deleted; nine remain, only for jobs the app had
never had to name (Media, Hashtags, Topics, Conversation, Search, Quoted posts,
Add-ons, Relay info, Other). Those twelve also needed translating and now do not.

Checking those two labels found three real mis-buckets from the sweep's rule
ordering, all now fixed:

  FilterHomePostsByAuthors        RELAY_LISTS -> HOME_FEED     (nip65Follows in the
  FilterPictureAndVideoByAuthors  RELAY_LISTS -> MEDIA_FEED     path matched first)
  FilterDraftsAndReportsFromKey   MODERATION  -> ACCOUNT_DATA  (kinds = DraftKinds;
                                                                "report" in the filename)

So "Relay lists · 173 relays" was mostly the home and media feed fan-out, and
"Moderation" was counting drafts. RELAY_LISTS now covers only the outbox finder
and MODERATION only reports-about-you, which is what the words claim.

SubPurposeLabels is the single place a purpose becomes words, shared by both
surfaces so they cannot drift. Which jobs earn a notification line is now derived
from the taxonomy — the ACCOUNT and MESSAGES groups — rather than a hand-kept
list that happened to contain the same twelve.

The two surfaces stay deliberately different: the notification names only the
dozen jobs that survive backgrounding, while the relay screen someone opened on
purpose shows every job, feeds and current-screen work included.

Verified on device, notification re-read from the system:

    Notifications · 168 · Reports · 164 · Follow List · 134 · Relay Chats · 19
    Profile · 14 · Wallet · 11 · Communities · 8 · Messages · 6
    Marmot Group · 5 · Drafts · 3 · Browsing · 165

The chips themselves are compile-verified and read from the same
already-device-verified data, but the rendered row has NOT been seen — reaching
that screen needs drawer navigation that was not worth scripting. Worth a look
before merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:57:05 -04:00
Vitor PamplonaandClaude Opus 5 3192e14c5a feat(notifications): explain the relay count, but only when asked
Renames SubPurpose.isBackground to runsInBackground, and puts the per-job
breakdown behind the notification's expand affordance.

The collapsed line is byte-for-byte what it was — "Connected to 188 relays" —
because that is all most people want from an ongoing notification, and this one
sits in the shade permanently. The breakdown goes in a BigTextStyle, so it costs
nothing until someone deliberately expands it to ask why their phone is talking
to that many relays. It is skipped entirely when nothing is attributed yet, so an
empty section can never render.

Verified by reading the notification back out of the system rather than trusting
the code path (`dumpsys notification --noredact`):

    android.text     Connected to 188 relays          <- unchanged
    android.bigText  Connected to 188 relays
                     Relay lists · 173 relays
                     Notifications · 169 relays
                     Moderation · 159 relays
                     Your follows · 154 relays
                     Public chats · 21 relays
                     Profiles · 12 relays
                     Wallet · 12 relays
                     Communities · 9 relays
                     Direct messages · 6 relays
                     Encrypted groups · 6 relays
                     Your account · 3 relays
                     Browsing · 173 relays

Only the twelve jobs worth naming to a user get a line. Feeds and whatever screen
is open have no label and collapse into "Browsing" — they tear themselves down
once the app is backgrounded, which is exactly when this notification matters, so
itemising them would add noise precisely when nobody is interested.

The counts overlap on purpose and sum well past the relay count: a typical relay
carries four jobs at once, so there is no partition to show. The copy answers "how
many relays carry my DMs", not "how is the pool split" — which is why the earlier
sketched wording ("4 for notifications, 3 for DMs") was dropped; it implied a
partition that does not exist.

Strings are one reused plural plus thirteen labels, so the count agrees with its
noun in languages that decline it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:39:06 -04:00
Vitor PamplonaandClaude Opus 5 3eedcd9f89 refactor(relays): split SubPurpose into specific jobs, grouped and lifetime-aware
The first pass put 208 of 343 filters into SCREEN_CONTENT — a bucket covering the
whole Discover tab, every media feed, search, threads, profiles, badges, chess
and a mis-filed wallet screen. Useless for explaining anything. MODERATION ended
up with zero filters despite kind-1984 subscriptions being live, and
ENCRYPTED_GROUPS with zero because Marmot builds its filters in quartz and wraps
them later, so the sweep's `filter = Filter(` pattern never saw them.

SubPurpose is now 27 specific jobs on two axes:

- `group` rolls them up (ACCOUNT / MESSAGES / FEEDS / CURRENT_SCREEN) so a
  notification can say something short while a relay screen or bug report keeps
  the fine value. Adding a fine value stays cheap.
- `isBackground` marks what may outlive the foreground.

SCREEN_CONTENT is gone, replaced by DISCOVER_FEED, MEDIA_FEED, TAG_FEED,
COMMUNITY_FEED, TOPIC_FEED, THREAD, USER_PROFILE, SEARCH, ENGAGEMENT,
REFERENCED_EVENTS, ADD_ONS, GAMES and RELAY_INFO. CHATS splits into PUBLIC_CHATS,
COMMUNITY_CHATS, ENCRYPTED_GROUPS and LIVE_ROOMS. Marmot is tagged through
ExplainedFilter.of() at the wrap site, since quartz cannot see commons.

Every one of the 27 values is applied by at least one filter — checked, not
assumed. Nothing in a relay-bound path is left untagged.

Measured on device, cold start then HOME:

    foreground @45s   13 purposes   HOME_FEED 337 relays · NOTIFICATIONS 329 ·
                                    RELAY_LISTS 337 · MODERATION 326 · ENGAGEMENT 20
    background @90s    9 purposes   all isBackground=true

Both `isBackground = false` purposes in flight (HOME_FEED, ENGAGEMENT) were gone
after backgrounding, which is the invariant worth having: a CURRENT_SCREEN
purpose alive in the background is a leak, and that is now assertable.

The same run corrected the flag's documentation. RELAY_LISTS and FOLLOW_LISTS are
marked background-capable yet absent at @90s — because those loaders had nothing
to fetch, not because they were torn down. So `isBackground` is a ceiling, not a
promise: absence proves nothing, presence of a `false` one proves a bug. The KDoc
now says that instead of implying the stronger claim.

Worth a second look before this ships: WALLET holds 21 filters across 12 relays
while backgrounded — more relays than DIRECT_MESSAGES (5) or NOTIFICATIONS (8).
That may be correct for nutzap watching, or it may be more sockets than the
feature warrants. The tagging is what makes the question askable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:27:19 -04:00
Vitor PamplonaandClaude Opus 5 8c9475a3fb feat(relays): tag every relay-bound filter with its purpose
Completes the sweep: all 343 filters that reach a relay now carry a SubPurpose,
so `client.activeRequests(relay)` can answer "what is this relay doing for me?"
for every connection, not just the three assemblers wired as proof.

Scope was bigger than the "12 assemblers" first estimated — that count was files
calling newSubId(). The real target is filters wrapped in RelayBasedFilter, since
those are the ones that reach the wire: 202 files. The other ~400 `Filter(` sites
in the app query LocalCache and never leave the device, so they are deliberately
untouched.

Purposes assigned by subsystem: DIRECT_MESSAGES for the DM/giftwrap paths,
NOTIFICATIONS for the inbox filters, FOLLOW_LISTS / PROFILE_METADATA /
RELAY_LISTS for the account loaders, CHATS for public channels, relay groups,
Concord, nests and Marmot, HOME_FEED for the follow feed, WALLET for Cashu and
nutzaps, and SCREEN_CONTENT for everything opened on demand (thread, profile,
hashtag, video, polls, music, podcasts, git repos, workouts, chess).

Verified on device rather than by inspection — 369 relays attributed through
client.activeRequests() after a cold start, with no untagged relay-bound filter
left (`0` by grep). Observed distribution across relays:

    FOLLOW_LISTS 921 · HOME_FEED 895 · PROFILE_METADATA 881 · NOTIFICATIONS 690
    CHATS 82 · SCREEN_CONTENT 81 · WALLET 38 · DIRECT_MESSAGES 17 · RELAY_LISTS 8

which reads correctly: the outbox fan-out puts follows/feed/metadata/notifications
on almost every relay, while DMs and relay-list discovery stay on the few relays
that actually serve them, and the account's own relay carries everything.

That measurement also corrects an assumption behind the planned notification
copy: relays are NOT partitioned by job. A typical relay serves four purposes at
once, so "4 for notifications, 3 for DMs" would be wrong — per-purpose counts
overlap and sum to more than the relay count. The popup wording needs to reflect
that, which is why it is still not written here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:59:23 -04:00
Vitor PamplonaandClaude Opus 5 6691e519e8 feat(relays): record why each subscription exists, without telling relays
The always-on notification says "connected to N relays" and nothing more. That
count is emergent, not curated: NotificationRelayService owns no subscriptions,
and nothing closes a socket when it stops being useful, so a relay stays
connected exactly as long as some filter still references it. Neither a user nor
a developer can tell whether those N relays are carrying DMs or re-dialling a
stale outbox hint.

The client already tracks every in-flight REQ per relay with its filters
(INostrClient.activeRequests, which the Connected Relays screen reads). A filter
says *what* is matched — kinds, authors, tags — but not *who asked*, and subIds
are random. This adds the missing half.

`Filter` becomes `open`, with `copy()` open too, and commons gets
`ExplainedFilter` carrying a `SubPurpose`. The purpose rides on the filter, so it
arrives with the data the relay screens already read — no parallel registry to
keep in sync or leak on teardown.

**It never reaches a relay.** FilterSerializer is registered against Filter and
writes an explicit protocol field list, and Jackson applies a serializer
registered for a class to its subclasses — so an ExplainedFilter serializes to
byte-identical JSON. That is the point: telling relays what each REQ is *for*
would hand them a ready-made fingerprint of client intent and correlate
subscriptions that are deliberately kept apart.

`copy()` is overridden because filters are copied on the live path — assemblers
call copy(since = …) after every EOSE. Inheriting the base implementation would
downgrade to a plain Filter on the first window refresh, so the purpose would
survive the opening REQ and vanish seconds later.

Both invariants are pinned by tests, and both were mutation-checked rather than
assumed:
- removing the copy() override      -> `copy preserves the purpose` fails
- unregistering FilterSerializer    -> 3 tests fail, one reporting the literal
                                       leak: `purpose leaked to the wire: {…}`
A test also asserts FiltersChanged does not see the new field, so tagging a
filter cannot trigger a re-REQ storm across ~400 relays.

Wired three assemblers as proof (metadata, reactions, outbox finder) and surfaced
the derived set on BasicRelaySetupInfo.purposes for the Connected Relays screen.
Verified on device: 8 relays attributed RELAY_LISTS through
client.activeRequests() — purpleplag.es, user.kindpag.es, indexer.coracle.social,
directory.yabu.me and friends, which is semantically right — across 2,709 sent
REQs containing zero occurrences of "purpose" or any SubPurpose name.

The remaining assemblers are untagged, which is why `purposes` is documented as
"not yet attributed" rather than "idle". Notification-popup copy is deliberately
not built yet: it should describe the measured background grouping, not the
intended one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 09:48:01 -04:00
539 changed files with 16130 additions and 6307 deletions
+5 -5
View File
@@ -22,7 +22,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -69,7 +69,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -126,7 +126,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -161,7 +161,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -220,7 +220,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
+11 -11
View File
@@ -53,7 +53,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -285,7 +285,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -337,7 +337,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -536,7 +536,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -586,7 +586,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -777,7 +777,7 @@ jobs:
- name: Upload to GH Release (skip on dry-run)
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ steps.ver.outputs.tag }}
@@ -831,17 +831,17 @@ jobs:
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
file: geode/Dockerfile
@@ -866,7 +866,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -1010,7 +1010,7 @@ jobs:
fi
- name: Upload Android assets to GH Release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
with:
files: dist/*
tag_name: ${{ github.ref_name }}
+2 -2
View File
@@ -28,7 +28,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
@@ -58,7 +58,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up JDK 21
uses: actions/setup-java@v5
uses: actions/setup-java@v5.6.0
with:
distribution: 'temurin'
java-version: 21
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,808 @@
# Location: foreground-gate the listener, trim the request, fix the meter
Date: 2026-07-29
Module: `amethyst`
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
Pixel 9a / Android 17)
Revision: 2 — incorporates spec review of 2026-07-29
## Context
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
seconds of app foreground**, and the accompanying analysis called it the leading
suspect for that day's 11 pp of background battery drain. Root cause given: 30
`SharingStarted.Eagerly` top-nav filter states on the account scope
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
That chain is real. **The battery conclusion drawn from it is not**, and this
spec is written against the measurement rather than the inference.
### What the device reports
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
`ACCESS_BACKGROUND_LOCATION` — and no service declares
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
`targetSdk = 37`.
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
Location Provider Data* block — not system-wide totals:
| provider | registration held | **active** | **foreground** | fixes |
|---|---|---|---|---|
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
Roughly **11 minutes of background-active location in three weeks**, and about
100 delivered fixes per provider. With the app backgrounded at the time of the
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
mStarted=false`.
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
**One figure does not reconcile, and is left open.** Registration-held is
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
across the two ledger days. If `location.ms` measured subscription existence
these should agree; they are ~2× apart. Candidates: segments open at process
death are lost (the pre-flush hook does not run on a kill, and the report shows
6 process starts across the two days); the ledger covers 2 days while dumpsys
spans 21 with different usage. Not investigated. It does not affect the
conclusion below, which rests on `location.ms` versus OS-*active* time, not
versus registration-held.
### How far the "no background location" claim generalises
This matters because the "no behaviour change" argument rests on it, so it is
scoped rather than asserted universally:
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
access, and for `targetSdk ≥ 29` an FGS additionally needs
`foregroundServiceType="location"`. Amethyst has neither, so background
registrations are suspended. This is the case the Pixel 9a measurement above
covers.
- **API 2628 (Android 89):** `ACCESS_BACKGROUND_LOCATION` does not exist.
Amethyst *can* receive background location there, throttled by the platform to
a few updates per hour. The gate is a genuine, if small, improvement on these
releases rather than a no-op.
So "structural" applies to API 29+; on 2628 the change has real effect.
### What is actually wrong
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
not how long anything listened. That is what made Finding 1 read as the
top-priority battery bug.
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
This burns during the 8 h 14 m the app genuinely is foreground.
3. **The subscription is held for 45 % of device uptime** doing nothing, because
`Eagerly` never lets go.
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
opt-in.
5. **Location may be entirely broken on Android 811** — see Hypothesis H1.
## Hypothesis H1 — location is dead below API 31 (unverified)
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
coarse-only app request any provider and receive a fuzzed result, is an Android
12 (API 31) change.
Amethyst holds coarse only. So on API 2630 today's `allProviders` loop should
throw `SecurityException` on three of the four providers. Two consequences:
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState`
`LackPermission`. Location would be **non-functional** on Android 811.
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
never called and any registration made before the throw **leaks** for the life
of the process.
**The leak is iteration-order dependent, and may not occur at all.**
`getLastKnownLocation` is permission-checked per provider too, and
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
iteration. If a fine-only provider comes first in `allProviders``passive`
does, in the common AOSP ordering — the throw lands before any registration
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
provider.
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
warning, not the runtime check, so this would not have been caught statically.
**Not reproduced.** Verify before implementing, on the existing
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
and capture **both** the `SecurityException` *and* the actual
`locationManager.allProviders` order (log it). The PR should claim only what that
run observed — "location is dead on Android 811" and "registrations leak" are
separate claims and the second may not hold.
The design below is written to be correct either way (§B excludes
permission-incompatible providers by API level, and catches `SecurityException`
per provider). If H1 holds, this change also **fixes location on Android 811**,
which should be called out in the PR.
### H1 verification result (2026-07-30, Pixel 9a, API 37)
Partial. The API-level claim could **not** be tested on this hardware: API 31+
grants coarse-only apps access to every provider, so no `SecurityException` can
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
What *was* settled is the ordering, which decides the leak sub-claim.
`dumpsys location` recent-events shows the same iteration order on every
registration cycle across two days, all four sharing one registration id
(`88A8E679`), confirming a single `LocationFlow` subscription:
```
07-30 07:09:58.282: passive provider +registration .../88A8E679
07-30 07:09:58.291: network provider +registration .../88A8E679
07-30 07:09:58.293: fused provider +registration .../88A8E679
07-30 07:09:58.299: gps provider +registration .../88A8E679
```
`allProviders` yields **passive first**, and `passive` is one of the fine-only
providers below API 31. So on Android 811 the throw would land on the first
iteration, before any registration exists:
- "location is dead on Android 811" — **still unverified**, needs API ≤ 30.
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
`getAllProviders()` could order differently on API 26.
**Unrelated but decisive "before" datum, same session:** with
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
`mLastPausedActivity`, Amethyst held **four** live registrations at
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
eliminate, captured on the owner's daily-driver device rather than an emulator.
## Goals
- Release the location registration whenever no activity is started.
- Register on one appropriate, permission-compatible provider at an interval
matched to the precision actually needed.
- Make `location.ms` reflect real listening time, correctly, under concurrency.
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
## Non-goals
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
gate in place its cost is bounded to foreground use. Worth revisiting
separately as a product decision.
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
- Findings 26 of the source analysis. Finding 2 (the relay reconnect storm,
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
the background battery drain and should be taken next.
## Design
### A. The gate
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
initialisation order is safe.
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
over *permission × foreground*, applied identically to `geohashStateFlow` and
`preciseGeohashStateFlow`:
| gate state | behaviour |
|---|---|
| no permission | emit `LackPermission` (unchanged) |
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
would make the "Around Me" feed flash empty on **every** return to foreground — a
new, frequent, user-visible regression introduced by this change. (It also fixes
the same flash on permission grant, which exists today.)
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
code emits `LackPermission` without touching `latestLocation`
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
attractive — a revoked permission arguably should not leave a fix readable — but
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
emitted. Clearing it would therefore buy no privacy and would cost an
empty-feed flash on every permission flap, which is precisely what R1 exists to
prevent. The cache is in-memory and dies with the process regardless.
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
not introduced. Both branches preserve today's behaviour exactly
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
permission). This corollary argues against *adding* a clear, not for removing
the existing one — changing it would be an unmotivated behaviour change. The
asymmetry is also defensible on its own terms: a source that failed mid-stream
says something about the fix's provenance, whereas a permission known to be
absent says nothing about a fix already taken.
**R2 — grace period on the background edge.** The gate must delay the
`foreground → background` transition by **5 s** before tearing down. Without it a
one-second app switch destroys and rebuilds the registration, including a full
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
intent. The `background → foreground` edge is **not** delayed.
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
delays both edges, and the duration-selector overload that would allow an
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
```kotlin
isForeground.transformLatest { fg ->
if (!fg) delay(BACKGROUND_GRACE_MS)
emit(fg)
}
```
`transformLatest` cancels the pending `delay` if foreground returns first, which
is exactly the stated semantics, with no preview opt-in.
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
synchronous `.value` reads across the feed filters
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
cell does not meaningfully decay while backgrounded.
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
values. R1 promotes them to control flow, read from a different coroutine than
the `onEach` that writes them. They must become `@Volatile` (or
`MutableStateFlow`).
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
to `WhileSubscribed`. Roughly 60 call sites read
`account.live*FollowLists.value` synchronously rather than collecting; under
`WhileSubscribed` those reads would silently serve a stale or initial value
whenever no collector happened to be active. That is a correctness regression,
not a battery fix.
**Rejected alternative:** gating only at the `AppModules` wiring point
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
same data.
### B. Request shape
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
**provider existence and permission compatibility** — both static facts:
```
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
API < 31, coarse → [NETWORK] filtered by exists (see H1)
```
It returns the **ordered candidate list**, not a single choice, because the
per-provider `SecurityException` fall-through below needs somewhere to fall to.
An empty list means no compatible provider exists.
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
registers regardless of enabled state, and such a registration goes live by
itself when the user enables location — including from the quick-settings shade
without leaving the app, which is exactly what someone does after seeing "Around
Me" empty. A guard evaluated once at subscription start would lose that, and the
foreground-transition restart does not cover the in-app path. Selecting on
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
field reports show dead feeds on devices where the chosen provider exists but is
disabled while another is enabled, adding that receiver is the follow-up.
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
that falls through to the next rung, so H1 cannot abort the builder and leak
registrations regardless of how the AOSP check actually behaves.
**When no provider can be registered** — the candidate list was empty, or every
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
way to express. Throwing routes it through the `.catch` already present in
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
without a live registration and no separate flag is needed. That is only half of
R5's pairing, though — see R5 for the release half, which the throw does **not**
cover and which needs `try`/`finally`.
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
That value renders `R.string.lack_location_permissions` — "No Location
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
which is wrong for a coarse-only pre-31 device that has no `network` provider.
The conflation is accepted rather than introduced: if H1 holds, today's
`SecurityException` already lands in the same `.catch` and shows the same wrong
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
`LocationState` (10 references across 5 files) and belongs with the H1 fix
messaging, not here. Recorded as a follow-up.
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
freshest result. It requires no registration and is what makes the first geohash
appear immediately rather than after a fix.
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
| flow | precision | interval / distance | provider set |
|---|---|---|---|
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
Both rows change: the ladder narrows the precise flow's provider set too, and
below API 31 that means `network` only, no GPS. Academic while the app holds
coarse only (see Follow-ups), but it is not "unchanged".
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
observable effect on the feed.
**Rejected alternative — one shared source at the fine profile,** deriving the
coarse geohash by prefix truncation. It halves registrations and removes the need
for `RefCountedSession` entirely, but it upgrades the **common** case — the
"Around Me" feed alone, which is always on via the Products default — from
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
**Rejected alternative — one shared source whose profile tracks the finest
active subscriber.** Recovers the above and is the best of the three on both
axes, but it is refcounting with the counter moved from the meter into the
request path, for a benefit bounded by how often the two flows overlap. They
overlap only while one of three composable-scoped, foreground-only screens is
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
### C. The meter
`AppModules.kt:251` hands both flows the same non-refcounted
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
while the other is still listening. Both can be live at once — the "Around Me"
feed plus an open geohash chat.
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
so it fires on *collection* whether or not anything was registered — meaning a
device with no usable provider accrues `location.ms` with nothing listening,
reintroducing the exact defect this section exists to fix. The hook must instead
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
without throwing, and again on the way out.
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
it fixes — twice over. First, `awaitClose` runs on every normal
completion, including one where no rung ever registered, so it would fire an
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
decrements a holder it never acquired, stealing another flow's. Concretely:
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
register and closes (`holders = 0`), and the session latches off while the coarse
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
negative.
Second — and this is the one that survives fixing the first — `awaitClose` also
fails to run at all on some paths that *did* register. See below.
The pair therefore has to be guaranteed from **both** ends, and the two ends need
different mechanisms.
*No acquire without a registration* is §B's **throw**: if no rung registers, the
builder throws before reaching the acquire at all.
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
is the subtlest point in the document, so the justification below is the one that
was **demonstrated**, not the one that sounds most obvious.
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
inside `awaitClose`, because `awaitClose` is never reached to register it. The
registration then leaks and the refcount sticks at ≥ 1 for the life of the
process, so `location.ms` accrues forever with nothing listening — this exact
defect, arrived at from the other direction, and unrecoverable once hit.
The **proven** path is the seed throwing a non-cancellation exception:
`getLastKnownLocation` is a binder call and can fail. The regression test
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
failing against an `awaitClose`-only implementation
(`expected:<[true, false]> but was:<[true]>`).
A cancellation during the seed is *in principle* a second such path, since `send`
is a suspending call. Recorded honestly: **this one could not be reproduced.**
Two attempts during implementation both produced tests that passed against a
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
so `send` returns without suspending and never observes the cancel. Do not treat
the cancellation story as the reason for the `try`/`finally`; a future reader who
tries to reproduce it, fails, and concludes the guard is unnecessary would
reintroduce the leak.
```kotlin
var registered: String? = null
for (provider in candidates) {
try {
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
registered = provider
break
} catch (e: SecurityException) { /* next rung */ }
}
if (registered == null) throw SecurityException("no usable location provider")
onListening?.invoke(true) // cannot fire without a registration
try {
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
awaitClose { } // only to satisfy callbackFlow's contract
} finally {
locationManager.removeUpdates(callback)
onListening?.invoke(false) // cannot be skipped
}
```
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
between them, so the acquire cannot happen outside the block that guarantees its
release.
`trySend` for the seed would also close this particular hole, being
non-suspending. It is rejected because it leaves the invariant resting on nobody
adding a suspending call to that block later — vigilance rather than
impossibility, which is the standard the rest of R5 is held to.
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
sufficient: two threads can leave the counter at 1 while the last
`setActive(false)` lands after the `setActive(true)`, latching the session off.
The count and the transition must move under one lock. New class in
`service/resourceusage/`:
```kotlin
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) =
synchronized(lock) {
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
setSessionActive(holders > 0)
}
}
```
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
that is all it needs, and because constructing a real integrator in a unit test
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
file to observe one boolean.
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
`onListening = { locationRefCount.setActive(it) }`. The outer
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
then nested but never acquired in the reverse order, so there is no deadlock.
`coerceAtLeast(0)` guards an unmatched release.
### What `location.ms` means after this change
Stated plainly, because the finding that opened this spec is "the meter lies"
and the next reader should not over-trust the fixed number the way the last one
over-trusted the broken one:
> `location.ms` measures **how long a location registration was held while the
> app was in the foreground**. It is not radio-on time and not an energy
> figure. A `network`-provider registration at 60 s costs close to nothing; a
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
> apart.
Reading it as a battery signal requires knowing which provider was chosen —
which the ledger does not record. Recording the chosen provider as a separate
counter is a possible follow-up.
## Testing
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
than the `Context`). That is the seam:
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
counting fake source:
- backgrounded + permitted → source never subscribed
- foreground + permitted → subscribed exactly once
- foreground → background → subscription released after the R2 grace period,
last `Success` still readable via `.value`
- background edge shorter than the grace period → subscription **not** torn down
- return to foreground with a cached `Success`**no** `Loading` emission (R1)
- return to foreground with no cached fix → `Loading` first
- permission revoked → `LackPermission` regardless of foreground state
**Provider ladder.** Extracted as a pure function
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
returned in order; missing rungs filtered out; API < 31 coarse-only yields
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
yields an empty list.
`hasFine` is **always `false` in production** — the non-goals rule out ever
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
that the function is total over the permission axis and the API < 31 branch can
be tested from both sides, not because fine access is anticipated. If that
changes, the ladder is already correct.
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
its own tests against a mocked `LocationManager` — see *Registration pairing*
below.
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
balanced pairs close it; an unmatched release does not drive the count negative.
Note what this class **cannot** do: it cannot distinguish an unpaired release
from a legitimate one, so `acquire → unpaired release` closes the session even
while another holder is listening. That is precisely the R5 bug, and
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
`LocationFlow`, not here** — which is why it needs its own test below.
**Registration pairing (R5).** To make this testable rather than device-only,
`LocationFlow` takes a `LocationManager` instead of a `Context`
(`LocationFlow(context)``LocationFlow(locationManager)`; the caller in
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
then covers:
- every rung throws `SecurityException` → the flow throws and `onListening` fires
**neither** edge
- `chooseProviders` returns an empty list → same: throws, neither edge
- an earlier rung throws and a later one succeeds → registration falls through,
exactly one `true`
- a successful registration → exactly one `true`, and exactly one `false` plus
`removeUpdates` on cancellation
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
→ both edges still fire and `removeUpdates` is still called. This is the case
the `try`/`finally` exists for; without it the test fails by hanging the
refcount at 1 rather than by throwing, so assert on the edges, not on the
absence of an exception.
These cover the *semantics* only — the two-thread interleaving that motivates the
lock is made unobservable by the lock itself and is not reproduced by any test
here.
## Acceptance criteria
On device, re-running the measurement above:
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
and a `-registration` in the recent-events log.
- **Foregrounded:** **one registration per actively-collected flow — at most
two**, and one in the steady state where only the "Around Me" feed is live
(§C exists precisely because the two flows may overlap). Not four. The coarse
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
`@+10s0ms` / `100.0`.
- The historical aggregates are cumulative since boot; compare deltas across a
foreground/background cycle, not absolute totals.
- **Invariant:** a subsequent in-app Resource Usage Report shows
```
location.ms ≤ app.fgms + 5 s × (background transitions)
```
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
without R2 this would hold exactly. R2 is deliberately the error term: the
registration really *is* live during the grace period, so counting it is the
honest reading, and a stated fudge factor beats an invariant quietly known to
be false. The term is not negligible — the source report shows 6 process
starts across two days, and app switches are far more frequent than that — so
writing `location.ms ≤ app.fgms` would guarantee that the first person to
check it files a bug against this change.
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
under-reporting, so a violation beyond the grace term indicts that counter
rather than this one.
- If H1 holds: location works on the API 26 AVD after the change and did not
before.
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
beside the untouched Play install. The Play install was force-stopped for the
duration so its own (unfixed) registrations could not be mistaken for these.
| criterion | before | after |
|---|---|---|
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
Event trace for one full cycle, process alive throughout (pid 28682):
```
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
```
- **§A gate** — zero registrations while backgrounded, with the process still
alive. That is the state the change exists to create; before, four
registrations survived screen-off and doze.
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
pair, so a brief switch no longer costs a re-registration and a fresh
`getLastKnownLocation` sweep.
**The OS aggregate after four foreground/background cycles is the headline
result**, because it is the same counter shape `location.ms` measures:
```
com.vitorpamplona.amethyst.benchmark:
min/max interval = 60s/60s
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
locations = 4
```
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
aggregate exactly, so nothing is held outside them. Registration-held time now
*equals* foreground time, which is precisely what makes `location.ms` honest: the
counter measures registration lifetime, and that quantity is no longer divorced
from reality.
**A fix arrives within milliseconds of every re-registration**, which bounds the
staleness the coarser profile was feared to introduce:
```
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
```
The `fused` provider hands over its cached fix on registration, so the window in
which a returning user could act on a stale geohash is milliseconds, not the 60 s
poll interval. Caveat: that cache is warm on this device because Maps and GMS
keep it fresh; on a device with no other location consumer it could be colder,
which is what `freshestLastKnownLocation` exists to cover.
**Side-by-side A/B, same device, same instant, both clients backgrounded and
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
5552) and the benchmark build of this branch (pid 28682) were sampled together:
```
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
← com.vitorpamplona.amethyst.benchmark: no rows at all
```
Four held registrations versus zero. Note the release client's rows are all
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
minutes and suspended delivery, exactly as §"What the device reports" describes —
but the **registration is still held**, and registration-held time is precisely
what `location.ms` counts. That is the inflation, visible in one frame.
Naming note for anyone re-reading the numbers above: both artifacts are `play`
**flavor** builds and differ only by buildType, so "the Play install" is an
ambiguous label. The unmodified client here is the *release* build, and on this
device it came from Obtainium rather than Google Play.
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
out against accumulated data:
| | `location.ms` | `app.fgms` | ratio |
|---|---|---|---|
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
requires ≥ 3 background transitions to fall inside the grace allowance — met by
the report navigation plus an `am start`. Day 20664 independently reconciles with
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
measurement ended. **The ledger and the OS now agree**, where before they were
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
**Limitation:** this is not a within-package before/after. `location.ms` is
absent from days 2064820663 because the benchmark client had location permission
*denied* until 2026-07-30; the "before" is no data, not inflated data.
### The same data closes the battery question
Over days 2065920665 on this device: **5m18s** of location listening against
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
meaningful contributor at that ratio — Finding 1 is settled, and not in the
direction the original analysis assumed.
Three consumers visible in the same report, none of them location:
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
foreground service running essentially continuously. Largest structural
difference from a stock client; worth confirming it is deliberately enabled.
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
original report's 75 %.
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
the fg/bg split in this report still cannot be trusted.
Caveat: the benchmark client's round-the-clock always-on service makes its
battery figures non-comparable to a stock install. The relay and `net.other`
figures do match the release client's original report closely.
### Smoke test on a clean install (2026-07-31, benchmark client)
Uninstalled and reinstalled from branch HEAD so the account, ledger and
permission grant all started empty — which is what makes the first-grant and
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
new data.
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
granted the permission, set a feed to Around Me, backgrounded for 10 s,
reopened — the geohash was present immediately with no blank feed. This was the
last open acceptance criterion on the branch and the only one no test could
cover. `dumpsys` shows why it works: the fix is delivered 16 ms after each
re-registration.
- **Precise profile live and distinct: PASS.** Geohash screens produce
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
production, not just in the unit tests.
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
came up at 15:49:10 and survived **four complete precise-flow cycles**
untouched (15:49:4146, 15:50:4752, 15:52:0715:53:05, 15:53:1722), with
both live simultaneously during the first. Opening and closing geohash chats
never closes the "Around Me" registration — `RefCountedSession` doing on a real
device what `LocationLedgerCompositionTest` asserts.
- **No hang in the location picker.** Every precise registration received a fix
within ~1 ms; none stuck open. That path does
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
crash if the gate failed to yield.
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
**Measurement note:** counting listeners with `grep -c` on the package name is
unreliable — the `service: ProviderRequest[…]` summary line also names the
package when it is the only requester, inflating the count by one. List the rows
and exclude that line instead. A count of zero is unambiguous either way.
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
5 s × transitions` invariant was separately confirmed from accumulated ledger
data (see above); the clean install reset that counter, so it will need another
day of use to re-check at scale.
## Follow-ups (not in this change)
- **`preciseGeohashStateFlow` is not actually building-level.** With only
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
the 8-char geohash and the location chat channels built on it are far coarser
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
it, or to stop advertising building-level precision, is a separate decision.
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
the boolean, as every other caller does (`LoggedInPage.kt:144`,
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
reflected back into the shared `LocationState`. Small, in the blast radius,
and cheap.
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
device with no usable provider stops being told "No Location Permissions" when
it has them. Ten references across five files
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
messaging — see the stated decision in §B.
- Recording the chosen provider as a ledger counter, so `location.ms` can be
read as a cost signal.
- Whether Products should still default to `TopFilter.AroundMe`.
- Finding 2 — the relay reconnect storm.
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
@@ -86,7 +85,6 @@ class NotificationFeedFilterModeOverrideTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings
@@ -77,7 +76,6 @@ class ThreadDualAxisChartAssemblerTest {
signer = NostrSignerInternal(keyPair),
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
okHttpClientForMoney = { OkHttpClient() },
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
@@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest {
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
/**
* Built outside composition on purpose: the mock and its error state are fixtures for the whole
* test, not per-composition state. Creating them inside `setContent` would rebuild both on every
* recomposition (and trips Compose's UnrememberedMutableState lint).
*/
private fun failedControllerState() =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
)
private fun renderInBox(
width: Dp,
height: Dp,
fontScale: Float = 1f,
) {
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) {
Box(Modifier.width(width).height(height)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest {
// button is measured before the weighted text block that absorbs the shortfall. Measure
// the same button roomy and then at its tightest, and require the two to agree.
val boxHeight = mutableStateOf(400.dp)
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest {
// was just tall enough to keep the icon and not tall enough to pay for it, so the title
// rendered sliced. Decoration must yield before words do.
val boxHeight = mutableStateOf(400.dp)
val controllerState = failedControllerState()
rule.setContent {
val density = LocalDensity.current.density
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
Box(Modifier.width(322.dp).height(boxHeight.value)) {
RenderPlaybackError(
controllerState =
MediaControllerState(
controller = mockk<Player>(relaxed = true),
playbackError =
mutableStateOf(
PlaybackException(
"Malformed HLS manifest",
null,
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
),
),
),
controllerState = controllerState,
videoUri = "https://streamstr.net/x/hls/live.m3u8",
)
}
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor
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.account.AccountSubscriptionRegistry
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
@@ -105,6 +106,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
@@ -246,10 +248,17 @@ class AppModules(
OtsSharedPreferences(appContext, applicationIOScope)
}
// App services that should be run as soon as there are subscribers to their flows
// App services that should be run as soon as there are subscribers to their
// flows. Location additionally releases its OS registration whenever no
// activity is started — see the foreground gate inside LocationState.
val locationManager by lazy {
Log.d("AppModules", "LocationManager Init")
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
LocationState(
appContext,
applicationIOScope,
isForeground = foregroundTracker.isForeground,
onListening = { locationRefCount.setActive(it) },
)
}
val connManager = ConnectivityManager(appContext, applicationIOScope)
@@ -374,6 +383,11 @@ class AppModules(
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
// LocationState exposes two independent flows that can both be listening at
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
// either one stopping from closing the other's segment.
private val locationRefCount = RefCountedSession(locationSession::setActive)
// Time-per-screen (route base names only — arguments never reach the
// ledger). Fed by the navigation listener in AppNavigation; foreground
// gating means backgrounding on a screen closes its segment.
@@ -871,7 +885,6 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
@@ -957,6 +970,12 @@ class AppModules(
)
}
// Gives every account that opted into running in the background its own
// account-level subscriptions (notifications, DMs, gift wraps, wallet), with no
// AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below,
// which already tracks which accounts opted in.
val accountSubscriptions = AccountSubscriptionRegistry(sources.account)
// Manages always-on notification service lifecycle. Preloads every saved
// writable account while enabled so GiftWraps for non-active accounts still
// get unwrapped by their owning account's newNotesPreProcessor.
@@ -966,6 +985,8 @@ class AppModules(
scope = applicationIOScope,
accountsCache = accountsCache,
localPreferences = LocalPreferences,
subscriptions = accountSubscriptions,
isForeground = foregroundTracker.isForeground,
activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey },
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
/*
* 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.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import kotlin.coroutines.cancellation.CancellationException
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
* metadata updates, group messaging, and key-package publishing. MLS state
* lives in [MarmotManager]; this class wires it to the account's signer, relay
* client, and relay lists. Functions live here (not a ViewModel) so headless
* callers - notification receivers, background workers - can drive them.
*/
class AccountMarmotActions(
private val account: Account,
) {
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
* canonical set; fall back to the account's outbox relays if the group
* has none (e.g. a group joined before MIP-01 metadata existed).
*
* Lives on Account (not AccountViewModel) so that headless callers —
* notifications' BroadcastReceiver, background workers — can resolve
* relays without spinning up a ViewModel.
*/
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
val groupRelays =
account.marmotManager
?.groupMetadata(nostrGroupId)
?.relays
?.mapNotNull {
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
.normalizeOrNull(it)
}?.toSet()
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
*/
suspend fun sendMarmotGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}" +
"${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
// Link the envelope to the inner message we just encrypted so relay
// OK acceptances drill down to the note the chat renders (see
// LocalCache.addRelayToNoteAndInners).
outbound.signedEvent.innerEventId = innerEvent.id
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before relay round-trip so the UI
// updates immediately.
account.marmotGroupList.markAsKnown(nostrGroupId)
if (groupRelays.isEmpty()) {
Log.w("MarmotDbg") {
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
}
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
* Returns a status message describing the outcome.
*/
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
suspend fun fetchKeyPackageAndAddMember(
nostrGroupId: HexKey,
memberPubKey: HexKey,
): String {
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}"
}
val manager = account.marmotManager ?: return "Error: Marmot not initialized"
if (!account.isWriteable()) return "Error: Account is read-only"
// Per MIP-00, invitees advertise the relays that host their
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
// there first, then fall back to the invitee's NIP-65 outbox
// (where KeyPackages typically also land), and finally union
// with our own outbox so we still find packages that ended up
// on a shared relay.
val myOutbox = account.outboxRelays.flow.value
val memberKeyPackageRelays =
(
account.cache
.getAddressableNoteIfExists(
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
.createAddress(memberPubKey),
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
)?.relays()?.toSet().orEmpty()
val memberOutbox =
account.cache
.getOrCreateUser(memberPubKey)
.outboxRelays()
?.toSet()
.orEmpty()
val fetchRelays =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
}
val event =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.fetchKeyPackage(account.client, memberPubKey, fetchRelays)
if (event == null) {
Log.w("MarmotDbg") {
"fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)"
}
return "Error: No KeyPackage found for this user. They may not have published one yet."
}
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}"
}
val keyPackageBase64 = event.keyPackageBase64()
if (keyPackageBase64.isBlank()) {
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
return "Error: KeyPackage event has empty content"
}
// The relays embedded in the WelcomeEvent tell the new member
// where to subscribe for subsequent GroupEvents. Use our own
// outbox — that's where we will publish them.
val groupRelays = myOutbox.toList()
Log.d("MarmotDbg") {
"fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}"
}
addMarmotGroupMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = event,
groupRelays = groupRelays,
)
return "Success: Member added to group"
}
/**
* Add a member to a Marmot MLS group.
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
*/
suspend fun addMarmotGroupMember(
nostrGroupId: HexKey,
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
groupRelays: List<NormalizedRelayUrl>,
) {
val memberPubKey = keyPackageEvent.pubKey
Log.d("MarmotDbg") {
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}" +
"groupRelays=${groupRelays.size}"
}
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val (commitEvent, welcomeDelivery) =
manager.addMember(
nostrGroupId = nostrGroupId,
keyPackageEvent = keyPackageEvent,
relays = groupRelays,
)
// The MLS commit has already been applied to the local group state —
// surface the new member list in the chatroom now so observers (e.g.
// MarmotGroupInfoScreen) update without waiting for our own commit to
// loop back through the relay.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}" +
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
}
// Publish commit first (critical ordering)
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
// Then send the Welcome gift wrap to the new member.
//
// Use the same delivery path that NIP-17 DMs (kind:1059) take —
// computeRelayListToBroadcast() — which has fallbacks for kind:10050
// → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the
// invitee, so this path is the one we know works. We also union
// with our own outbox + the recipient's dmInboxRelays() as a
// belt-and-braces measure in case the cache hasn't been hydrated
// yet for this contact.
if (welcomeDelivery != null) {
val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
val recipientInbox =
account.cache
.getOrCreateUser(memberPubKey)
.dmInboxRelays()
.orEmpty()
val relayList = computed + account.outboxRelays.flow.value + recipientInbox
Log.d("MarmotDbg") {
"addMarmotGroupMember: welcome gift wrap relay sources " +
"computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " +
"recipientInbox=${recipientInbox.size} → union=${relayList.size}"
}
if (relayList.isEmpty()) {
Log.w("MarmotDbg") {
"addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped"
}
} else {
Log.d("MarmotDbg") {
"addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}" +
"kind:${welcomeDelivery.giftWrapEvent.kind}${relayList.size} relay(s): ${relayList.map { it.url }}"
}
}
account.client.publish(welcomeDelivery.giftWrapEvent, relayList)
} else {
Log.w("MarmotDbg") {
"addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!"
}
}
}
/**
* Relays where this account publishes kind:30443 KeyPackage events.
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
*/
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
/**
* Publish or rotate KeyPackage events.
*/
suspend fun publishMarmotKeyPackages() {
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" }
return
}
val relays = keyPackagePublishRelays()
val needsRotation = manager.needsKeyPackageRotation()
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}"
}
if (needsRotation) {
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)"
}
rotatedEvents.forEach { event ->
account.cache.justConsumeMyOwnEvent(event)
Log.d("MarmotDbg") {
"publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}" +
"${relays.size} relay(s): ${relays.map { it.url }}"
}
account.client.publish(event, relays)
}
}
}
/**
* Generate and publish initial KeyPackage for this account.
*/
suspend fun publishMarmotKeyPackage() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val relays = keyPackagePublishRelays()
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
}
val event = manager.generateKeyPackageEvent(relays.toList())
Log.d("MarmotDbg") {
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}"
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
}
/**
* Ensure the local user has at least one active KeyPackage bundle and
* a published KeyPackage event on relays. Called from [init] after
* Marmot state has been restored from disk.
*
* - If [KeyPackageRotationManager] already has an active bundle (from
* the persisted snapshot), we trust the previous session and do
* nothing. The matching kind:30443 should already be on relays from
* when the bundle was first generated.
* - Otherwise we generate a fresh bundle (which is now persisted to
* disk by [KeyPackageRotationManager.generateKeyPackage]) and
* publish the corresponding event.
*
* Best-effort: failures are logged but never propagated. We don't want
* a flaky relay or missing outbox config at startup to crash account
* initialization.
*/
internal suspend fun ensureMarmotKeyPackagePublished() {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
try {
val hasBundle = manager.hasActiveKeyPackages()
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}"
}
if (hasBundle) {
return
}
Log.d("MarmotDbg") {
"ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now"
}
publishMarmotKeyPackage()
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e)
}
}
/**
* Check if a KeyPackage has been published in this session.
* The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
* persisted snapshot, so there is no fixed address to query in the cache.
*/
suspend fun hasPublishedKeyPackage(): Boolean {
val manager = account.marmotManager ?: return false
return manager.hasActiveKeyPackages()
}
/**
* Create a new Marmot MLS group.
*/
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
manager.createGroup(nostrGroupId)
// Creator owns the group — mark it as "known" immediately so it
// doesn't appear under "New Requests" before the first message.
account.marmotGroupList.markAsKnown(nostrGroupId)
}
/**
* Leave a Marmot MLS group.
* Publishes the SelfRemove proposal and removes local state.
*
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
* commit dropping themselves from `admin_pubkeys` before issuing a
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
* SelfRemove proposal both go to the same group relays, demote first so
* peers apply it before they see the SelfRemove.
*/
suspend fun leaveMarmotGroup(
nostrGroupId: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId)
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
// MIP-03 also rejects any GCE commit that leaves the group with zero
// admins. If we're the only one, promote an arbitrary non-self
// member to admin before stepping down.
if (remaining.isEmpty()) {
val heir =
manager
.memberPubkeys(nostrGroupId)
.map { it.pubkey }
.firstOrNull { it != account.signer.pubKey }
if (heir != null) remaining.add(heir)
}
if (remaining.isNotEmpty()) {
val demoted = metadata.copy(adminPubkeys = remaining)
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
account.client.publish(demoteCommit.signedEvent, groupRelays)
}
}
val outbound = manager.leaveGroup(nostrGroupId)
// manager.leaveGroup already wiped MLS state, relay subscriptions and
// the persisted message log. Drop the in-memory chatroom too — that
// releases the strong refs to the decrypted inner notes so LocalCache
// (which holds them weakly) can GC them, and the Notification feed
// (which iterates account.marmotGroupList.rooms) stops surfacing the group.
account.marmotGroupList.removeGroup(nostrGroupId)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* User-initiated "nuclear" reset for the Marmot subsystem.
*
* Wipes every MLS group, every retained epoch secret, every persisted
* KeyPackage bundle, every relay subscription and every in-memory
* chatroom associated with this account. Does NOT broadcast any
* SelfRemove/leave commits to peers — if the user is in this flow at
* all, local state may already be unusable and a graceful leave is
* probably not possible. Peers will see the user as unresponsive until
* their next commit evicts the stale leaf.
*
* A fresh KeyPackage will be republished lazily on the next
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
* reachable for future group invites.
*/
suspend fun resetMarmotState() {
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}" }
account.marmotManager?.resetAllState()
for (groupId in account.marmotGroupList.allGroupIds()) {
account.marmotGroupList.removeGroup(groupId)
}
}
/**
* Remove a member from a Marmot MLS group.
* Publishes the commit GroupEvent to group relays.
*/
suspend fun removeMarmotGroupMember(
nostrGroupId: HexKey,
targetLeafIndex: Int,
groupRelays: Set<NormalizedRelayUrl>,
) {
Log.d("MarmotDbg") {
"removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " +
"groupRelays=${groupRelays.size}"
}
val manager =
account.marmotManager ?: run {
Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" }
return
}
if (!account.isWriteable()) {
Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" }
return
}
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}"
}
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
Log.d("MarmotDbg") {
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}" +
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
}
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Update a Marmot MLS group's metadata (name, description, etc.).
* Publishes the commit GroupEvent to group relays.
*/
suspend fun updateMarmotGroupMetadata(
nostrGroupId: HexKey,
metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
// The MLS commit has already been applied locally — surface the new
// metadata in the chatroom now so the UI reflects it without waiting
// for the relay round-trip.
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
manager.syncMetadataTo(nostrGroupId, chatroom)
account.client.publish(outbound.signedEvent, groupRelays)
}
/**
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
*
* No-op if the group has no prior metadata (shouldn't happen outside the
* first bootstrap commit) or the target is already an admin. Callers
* must be an admin themselves — the MLS engine enforces this via the
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
*/
suspend fun grantMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (metadata.adminPubkeys.contains(targetPubKey)) return
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
/**
* Revoke admin privileges from [targetPubKey]. Rejects any change that
* would leave the group with zero admins — MIP-03's admin-depletion guard
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
* throw at commit time.
*/
suspend fun revokeMarmotGroupAdmin(
nostrGroupId: HexKey,
targetPubKey: HexKey,
groupRelays: Set<NormalizedRelayUrl>,
) {
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val metadata = manager.groupMetadata(nostrGroupId) ?: return
if (!metadata.adminPubkeys.contains(targetPubKey)) return
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
check(remaining.isNotEmpty()) {
"Cannot revoke the last admin from a Marmot group (MIP-03)"
}
val outboxRelayStrings =
account.outboxRelays.flow.value
.map { it.url }
val updated =
metadata
.copy(adminPubkeys = remaining)
.withMergedRelays(outboxRelayStrings)
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
}
}
@@ -0,0 +1,554 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
import com.vitorpamplona.quartz.utils.RandomInstance
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
* join/leave/create/delete/archive groups, threads, invites, pins, member and
* role management, metadata edits, plus the Buzz dialect's DMs, jobs,
* workflows, and typing signals. Event building lives in quartz builders;
* this class wires them to the account's signer and the group's host relay.
*/
class AccountRelayGroupActions(
private val account: Account,
) {
// All group commands are published ONLY to the group's host relay, where
// relay29 authorizes them. The relay is the source of truth; the kind-10009
// list is our own cross-device bookkeeping of what we joined.
/** Send a kind 9021 join request to the group's host relay and remember it. */
suspend fun joinRelayGroup(
channel: RelayGroupChannel,
code: String? = null,
) {
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.follow(channel)
}
/**
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
*/
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
if (!account.isWriteable()) return
val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
account.client.publish(signed, setOf(channel.groupId.relayUrl))
}
/**
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
* We never assign the channel id ourselves, so callers discover the materialized DM
* by watching that registry rather than from this call's return.
*/
suspend fun openBuzzDm(
relay: NormalizedRelayUrl,
participants: List<HexKey>,
): String? {
val signed = account.signer.sign(DmOpenEvent.build(participants))
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
var results = account.client.publishAndCollectResults(signed, setOf(relay))
var channelId = buzzDmChannelIdFromAck(results)
// NIP-42 write race: on a cold connection the relay rejects the first publish with
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
account.client.fetchAllWithHooks(
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
timeoutMs = 8_000,
pendingOnAuthRequired = true,
) { _, _ -> false }
results = account.client.publishAndCollectResults(signed, setOf(relay))
channelId = buzzDmChannelIdFromAck(results)
}
return channelId
}
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
results.values
.firstOrNull { it.accepted }
?.message
?.substringAfter("\"channel_id\":\"", "")
?.substringBefore('"')
?.takeIf { it.isNotBlank() }
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
val template = DmHideEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
suspend fun addBuzzDmMember(
channel: RelayGroupChannel,
member: HexKey,
) {
val template = DmAddMemberEvent.build(channel.groupId.id, member)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
* channel may accept it. Returns the new job id (the request event id), or null when the
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
*/
suspend fun fileBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
request: String,
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null))
// Reflect it locally so the board updates immediately (publish only sends to relays).
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
suspend fun cancelBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
) {
if (!account.isWriteable()) return
val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
}
/**
* Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on
* [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the
* approval token), returned here. A run pauses on a human-approval gate before anything ships —
* see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator].
*/
suspend fun triggerBuzzWorkflow(
relay: NormalizedRelayUrl,
channelId: String,
workflowId: String,
task: String,
): HexKey? {
if (!account.isWriteable()) return null
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/**
* Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an
* addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a
* human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
*/
suspend fun publishBuzzWorkflowDef(
relay: NormalizedRelayUrl,
channelId: String,
name: String,
yaml: String,
): String? {
if (!account.isWriteable()) return null
val workflowId = RandomInstance.randomChars(16)
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return workflowId
}
/**
* Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which
* doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work.
* Publishing to the single group [relay]; the runner discovers the decision by author.
*/
suspend fun approveBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */
suspend fun denyBuzzWorkflowRun(
relay: NormalizedRelayUrl,
runId: HexKey,
note: String = "",
): HexKey? {
if (!account.isWriteable()) return null
val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note))
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
return signed.id
}
/**
* Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging
* the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
*/
suspend fun upvoteBuzzJob(
relay: NormalizedRelayUrl,
channelId: String,
jobId: HexKey,
jobAuthor: HexKey?,
) {
if (!account.isWriteable()) return
val template =
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
addUnique(ETag.assemble(jobId, null, null))
jobAuthor?.let { addUnique(PTag.assemble(it, null)) }
addUnique(arrayOf("k", JobRequestEvent.KIND.toString()))
addUnique(GroupIdTag.assemble(channelId))
}
val signed = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(signed)
account.client.publish(signed, setOf(relay))
}
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
val template = LeaveRequestEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.unfollow(channel)
}
/**
* Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
* just removing me; the relay drops the group and its messages. Also drops it from our own list
* so it disappears from Messages immediately instead of lingering as a now-dead id.
*/
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
val template = DeleteGroupEvent.build(channel.groupId.id)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
account.unfollow(channel)
// Remember the deletion so the channel leaves the community's browse list immediately and
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
RelayGroupDeletions.markDeleted(channel.groupId)
}
/**
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
* the new group's id.
*/
suspend fun createRelayGroup(
relay: NormalizedRelayUrl,
groupId: String,
name: String,
about: String? = null,
picture: String? = null,
isPrivate: Boolean = false,
isClosed: Boolean = false,
isHidden: Boolean = false,
isRestricted: Boolean = false,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = null,
channelType: String? = null,
): GroupId {
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
// a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay
// publish two events and produce nothing at all.
account.broadcaster.signAndSendPrivatelyOrBroadcast(
CreateGroupEvent.build(
groupId = groupId,
name = name,
about = about,
visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN,
channelType = channelType,
),
) { listOf(relay) }
val edit =
EditMetadataEvent.build(
groupId,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
val id = GroupId(groupId, relay)
account.follow(LocalCache.getOrCreateRelayGroupChannel(id))
return id
}
/**
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
* restrictive counterpart — so only the enabled restrictive flags are added.
*/
private fun relayGroupStatus(
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
): Set<GroupMetadataEvent.GroupStatus> =
buildSet {
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
}
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
suspend fun postRelayGroupThread(
channel: RelayGroupChannel,
title: String,
body: String,
) {
val template =
ThreadEvent.build(body, title) {
hTag(channel.groupId.id)
previous(channel.previousEventRefs(account.pubKey))
}
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
suspend fun createRelayGroupInvite(
channel: RelayGroupChannel,
code: String,
) {
val template = CreateInviteEvent.build(channel.groupId.id, code)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
*/
suspend fun updateRelayGroupPins(
channel: RelayGroupChannel,
pinnedEventIds: List<HexKey>,
) {
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
suspend fun pinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
}
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
suspend fun unpinRelayGroupMessage(
channel: RelayGroupChannel,
eventId: HexKey,
) {
if (!channel.isPinned(eventId)) return
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
}
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
suspend fun removeRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
) {
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
* event (moderator only). Pass an empty [roles] list for a plain member.
*/
suspend fun putRelayGroupUser(
channel: RelayGroupChannel,
pubkey: HexKey,
roles: List<String>,
) {
// Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its
// own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
val buzzRole =
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
when {
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
else -> BUZZ_ROLE_MEMBER
}
} else {
null
}
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
* [relay] with no channel scope.
*/
suspend fun addCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
role: String? = null,
) {
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
}
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
suspend fun removeCommunityMember(
relay: NormalizedRelayUrl,
pubkey: HexKey,
) {
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
}
/**
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
*
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
* existing `child` is rejected by the relay. So unless the caller is explicitly
* re-parenting, we re-carry the group's current [parent] and full [children] list
* from its latest known metadata to keep the tree intact across a plain name/flag
* edit. Pass an explicit value to change them.
*/
suspend fun editRelayGroupMetadata(
channel: RelayGroupChannel,
name: String?,
about: String?,
picture: String?,
isPrivate: Boolean,
isClosed: Boolean,
isHidden: Boolean,
isRestricted: Boolean,
hashtags: List<String> = emptyList(),
geohashes: List<String> = emptyList(),
parent: String? = channel.parentGroupId(),
children: List<String> = channel.childGroupIds(),
) {
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
val template =
EditMetadataEvent.build(
channel.groupId.id,
name = name,
about = about,
picture = picture,
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
hashtags = hashtags,
geohashes = geohashes,
parent = parent,
children = children,
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
/**
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
* history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
*/
suspend fun archiveRelayGroup(
channel: RelayGroupChannel,
archived: Boolean,
) {
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
}
}
@@ -0,0 +1,337 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
/**
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
* onchain zaps/sends. Event building lives in the commons ZapActions/
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
* routing to the account.
*/
class AccountZapActions(
private val account: Account,
) {
suspend fun createZapRequestFor(
event: Event,
pollOption: Int?,
message: String = "",
zapType: LnZapEvent.ZapType,
toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create(
zappedEvent = event,
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
signer = account.signer,
pollOption = pollOption,
message = message,
zapType = zapType,
toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
suspend fun calculateIfNoteWasZappedByAccount(
zappedNote: Note?,
afterTimeInSeconds: Long,
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
suspend fun sendNwcRequest(
request: Request,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
account.client.publish(event, setOf(relay))
}
suspend fun sendNwcRequestToWallet(
walletUri: Nip47WalletConnect.Nip47URINorm,
request: Request,
onResponse: (Response?) -> Unit,
): HexKey {
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
account.client.publish(event, setOf(relay))
return event.id
}
/**
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
* the given request id. 0 if the request is unknown or already resolved.
*/
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
/**
* Removes a pending NIP-47 request from the tracker. Call this when the
* UI gives up waiting (timeout) so the entry doesn't stick around.
*/
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
suspend fun sendZapPaymentRequestFor(
bolt11: String,
zappedNote: Note?,
onResponse: (Response?) -> Unit,
) {
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
account.client.publish(event, setOf(relay))
}
/**
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
* info event (its capability advertisement), which [NwcSignerState] already refreshes
* on wallet change. A missing/unfetched info event reads as false, so the zap path
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
*/
fun defaultWalletSupportsBolt12Pay(): Boolean {
val uri = account.nip47SignerState.defaultWalletUri.value ?: return false
return account.nip47SignerState.infoCache
?.current(uri)
?.supportsMethod(NwcMethod.PAY) == true
}
/**
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
*
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
* fails the binding check, so no invalid receipt is ever published (the payment
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
* external-wallet or LNURL fallback because only NWC returns the proof.
*/
suspend fun sendBolt12Zap(
zappedEvent: Event?,
recipientPubKey: HexKey,
offer: String,
amountMillisats: Long,
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
onProcessed: () -> Unit,
) {
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
if (zapType == LnZapEvent.ZapType.NONZAP) {
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
account.scope.launch {
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
onProcessed()
}
}
return
}
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
val intent =
if (zappedEvent == null) {
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
} else {
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
}
val payerNote = Bolt12ZapBuilder.payerNote(intent)
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
account.scope.launch {
// try/finally so a failure while assembling/publishing the receipt (e.g. a
// remote signer error) still steps progress and surfaces an error, instead
// of vanishing as an uncaught coroutine exception. The payment already
// settled at this point, so such a failure means "paid, no receipt".
try {
when (response) {
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
account.cache.justConsumeMyOwnEvent(zap)
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
}
}
}
suspend fun createZapRequestFor(
user: User,
message: String = "",
zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent {
val zapRequest =
LnZapRequestEvent.create(
userHex = user.pubkeyHex,
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
signer = account.signer,
message = message,
zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
)
account.cache.justConsumeMyOwnEvent(zapRequest)
return zapRequest
}
private fun onchainBackendNotConfigured() =
OnchainZapSendResult.Failure(
OnchainZapSendStage.LOADING_UTXOS,
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
ONCHAIN_BACKEND_NOT_CONFIGURED,
)
/**
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
* leave it null for a profile zap.
*/
suspend fun sendOnchainZap(
recipientPubKey: HexKey,
amountSats: Long,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.send(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientPubKey = recipientPubKey,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
/**
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
*/
suspend fun sendOnchainToAddress(
recipientAddress: String,
amountSats: Long,
feeRateSatPerVByte: Double,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendToAddress(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipientAddress = recipientAddress,
amountSats = amountSats,
feeRateSatPerVByte = feeRateSatPerVByte,
)
}
/**
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
* each recipient their precomputed share, plus one kind:8333 receipt per
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
*/
suspend fun sendOnchainZapWithSplits(
recipients: List<OnchainZapShare>,
feeRateSatPerVByte: Double,
comment: String = "",
zappedEvent: EventHintBundle<out Event>? = null,
): OnchainZapSendResult {
val backend =
account.cache.onchainBackend
?: return onchainBackendNotConfigured()
return OnchainZapSender.sendSplit(
backend = backend,
signer = account.signer,
senderPubKey = account.signer.pubKey,
recipients = recipients,
feeRateSatPerVByte = feeRateSatPerVByte,
comment = comment,
zappedEvent = zappedEvent,
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
}
}
@@ -0,0 +1,492 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Memory-reclaim policy over the [LocalCache] stores: trims the soft caches,
* prunes hidden/old/expired/superseded events, and owns the shared
* [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on.
*
* Pure policy — it holds no state of its own beyond the cache reference, so every
* function can be exercised against a populated cache in tests. Driven by
* `MemoryTrimmingService`.
*/
class CachePruner(
private val cache: LocalCache,
) {
fun cleanMemory() {
Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" }
cache.notes.cleanUp()
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" }
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" }
cache.addressables.cleanUp()
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" }
Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" }
cache.users.cleanUp()
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" }
}
fun cleanObservers() {
cache.notes.forEach { _, it -> it.clearFlow() }
cache.addressables.forEach { _, it -> it.clearFlow() }
}
private fun pruneHiddenMessagesChannel(
channel: Channel,
account: Account,
) {
val toBeRemoved = channel.pruneHiddenMessages(account)
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneHiddenMessages(account: Account) {
cache.ephemeralChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.geohashChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.liveChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.publicChatChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneHiddenMessagesChannel(channel, account)
}
}
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
// `NestsFeedFilter` so a presence still inside any feed's window
// can never be pruned.
private val presencePruneAgeSeconds = 20L * 60L
private fun pruneOldMessagesChannel(channel: Channel) {
val toBeRemoved = channel.pruneOldMessages()
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Audio-room presence is keyed separately from `notes` and
// never gets reaped by the top-N rule. Drop entries older
// than 2× the 10-min freshness window so the index doesn't
// grow unbounded with every author who ever heartbeat here.
if (channel is LiveActivitiesChannel) {
channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds)
}
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
println(
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
)
}
}
fun pruneOldMessages() {
checkNotInMainThread()
cache.ephemeralChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.geohashChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.liveChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.publicChatChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.relayGroupChannels.forEach { _, channel ->
pruneOldMessagesChannel(channel)
}
cache.chatroomList.forEach { userHex, room ->
// History floors are pinned per scope on first advance; null means that window never paged
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
// filter when accumulating below.
val giftWrapFloor = room.giftWrapHistory.floor
val accountNip04Floor = room.nip04History.floor
room.rooms.map { key, chatroom ->
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
val childrenToBeRemoved = mutableListOf<Note>()
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
// kind:4 message, so rooms that never paged conversation history pay nothing.
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
toBeRemoved.forEach { note ->
when (val ev = note.event) {
is BaseDMGroupEvent ->
if (giftWrapFloor != null) {
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
}
is PrivateDmEvent -> {
val until = ev.createdAt
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
}
}
childrenToBeRemoved.addAll(removeIfWrap(note))
unlinkAndRemove(note)
childrenToBeRemoved.addAll(note.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
// re-requests it on the next demand-advance instead of skipping the hole.
if (giftWrapPruned.isNotEmpty()) {
room.giftWrapHistory.rewindTo(giftWrapPruned)
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
}
if (accountNip04Pruned.isNotEmpty()) {
room.nip04History.rewindTo(accountNip04Pruned)
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
}
if (roomNip04Pruned.isNotEmpty()) {
chatroom.nip04History.rewindTo(roomNip04Pruned)
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
}
if (toBeRemoved.size > 1) {
println(
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
)
}
}
}
}
private fun removeIfWrap(note: Note): List<Note> {
val host = note.rumorHost ?: return emptyList()
val children = mutableListOf<Note>()
cache.getNoteIfExists(host.id)?.let { hostNote ->
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
cache.getNoteIfExists(sealId)?.let { sealNote ->
unlinkAndRemove(sealNote)
children.addAll(sealNote.clearChildLinks())
}
}
unlinkAndRemove(hostNote)
children.addAll(hostNote.clearChildLinks())
}
note.rumorHost = null
return children
}
fun prunePastVersionsOfReplaceables() {
val toBeRemoved =
cache.notes.filter { _, note ->
val noteEvent = note.event
if (noteEvent is AddressableEvent) {
noteEvent.createdAt <
(
cache.addressables
.get(noteEvent.address())
?.event
?.createdAt ?: 0
)
} else {
false
}
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) }
if (newerVersion != null) {
it.moveAllReferencesTo(newerVersion)
}
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
}
}
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
checkNotInMainThread()
val toBeRemoved =
cache.notes.filter { _, note ->
(
(note.event is TextNoteEvent && !note.isNewThread()) ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is ReportEvent ||
note.event is GenericRepostEvent
) &&
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
note.flowSet?.isInUse() != true &&
// don't delete if observing.
note.author?.pubkeyHex !in
accounts &&
// don't delete if it is the logged in account
note.event?.isTaggedUsers(accounts) !=
true // don't delete if it's a notification to the logged in user
}
val childrenToBeRemoved = mutableListOf<Note>()
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (toBeRemoved.size > 1) {
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
}
}
/**
* Unlinks [note] from everything in the cache that references it, then drops it
* from the notes map and notifies observers. This is the shared "unlink from
* above" half of removal, used by both the prune callers and [LocalCache.deleteNote].
*
* It detaches the note from:
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
* because event-level reports and torrent comments both carry the target in
* `replyTo`, [Note.removeNote] cleans those up here too;
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
* resolve so a note can never linger in a channel after leaving the cache);
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
* reported addresses, contact cards, statuses, and poll responses.
*
* It deliberately does NOT touch the note's own children: prune callers collect
* them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote]
* keeps them and severs only their back-reference. Every per-target removal is
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
* event-level report reachable both ways) is harmless. Addressable notes are
* dropped from the addressables map by the caller; this only removes from notes.
*/
fun unlinkAndRemove(note: Note) {
note.replyTo?.forEach { masterNote ->
masterNote.removeNote(note)
}
note.inGatherers?.forEach { it.removeNote(note) }
cache.getAnyChannel(note)?.removeNote(note)
val noteEvent = note.event
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
// detach this note from every quoted note's boosts here.
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
cache.getNoteIfExists(quotedId)?.removeBoost(note)
}
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
// and drop it there, or a deleted edit would keep overlaying its message.
editedTargetIdOf(noteEvent)?.let { cache.getNoteIfExists(it)?.removeEdit(note) }
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
if (noteEvent is OtsEvent) {
noteEvent.digestEventId()?.let { cache.getNoteIfExists(it)?.removeTimestamp(note) }
}
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
}
noteEvent.reportedPost().forEach {
cache.getNoteIfExists(it.eventId)?.removeReport(note)
}
noteEvent.reportedAddresses().forEach {
cache.getAddressableNoteIfExists(it.address)?.removeReport(note)
}
}
if (note is AddressableNote && noteEvent is ContactCardEvent) {
cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
}
if (note is AddressableNote && noteEvent is StatusEvent) {
note.author?.statusStateOrNull()?.removeStatus(note)
}
if (noteEvent is PollResponseEvent) {
noteEvent.poll()?.eventId?.let {
cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
}
}
note.clearFlow()
cache.notes.remove(note.idHex)
cache.refreshDeletedNoteObservers(note)
}
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
private fun editedTargetIdOf(event: Event?): HexKey? =
when (event) {
is TextNoteModificationEvent -> event.editedNote()?.eventId
is ConcordChatEditEvent -> event.editedMessageId()
is StreamMessageEditEvent -> event.editedMessage()
else -> null
}
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
}
fun pruneExpiredEvents() {
checkNotInMainThread()
val now = TimeUtils.now()
val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
val childrenToBeRemoved = mutableListOf<Note>()
versionsToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
addressesToBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
}
}
fun pruneHiddenEvents(account: Account) {
checkNotInMainThread()
val childrenToBeRemoved = mutableListOf<Note>()
val toBeRemoved =
account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex ->
(cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
}
toBeRemoved.forEach {
unlinkAndRemove(it)
childrenToBeRemoved.addAll(it.clearChildLinks())
}
unlinkAndRemove(childrenToBeRemoved)
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
}
}
@@ -0,0 +1,266 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
import com.vitorpamplona.quartz.utils.DualCase
import kotlinx.coroutines.CancellationException
/**
* Prefix/content search over the [LocalCache] stores: users, notes, and the
* public-chat / ephemeral / live-activity channel maps. Pure read-side policy —
* no state beyond the cache reference — so ranking and filtering rules can be
* tested against a populated cache.
*/
class CacheSearch(
private val cache: LocalCache,
) {
fun findUsersStartingWith(
username: String,
forAccount: Account?,
): List<User> {
if (username.isBlank()) return emptyList()
checkNotInMainThread()
val key = decodePublicKeyAsHexOrNull(username)
if (key != null) {
val user = cache.getUserIfExists(key)
if (user != null) {
return listOfNotNull(user)
}
}
val dualCase =
listOf(
DualCase(username.lowercase(), username.uppercase()),
)
val finds =
cache.users.filter { _, user: User ->
val metadata = user.metadataOrNull()
if (metadata == null) {
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
} else {
(
metadata.anyNameOrAddressContains(dualCase) ||
user.pubkeyHex.startsWith(username, true) ||
user.pubkeyNpub().startsWith(username, true)
) &&
(forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase)))
}
}
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
return finds.sortedWith(
compareBy(
{ findsFollowing[it] == false },
{ anyNameStartsWith[it] == false },
{ anyAddressStartsWith[it] == false },
{ displayNames[it] },
{ it.pubkeyHex },
),
)
}
/**
* Will return true if supplied note is one of events to be excluded from
* search results.
*/
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
(
note.event is GenericRepostEvent ||
note.event is RepostEvent ||
note.event is CommunityPostApprovalEvent ||
note.event is ReactionEvent ||
note.event is LnZapEvent ||
note.event is LnZapRequestEvent ||
note.event is FileHeaderEvent ||
note.event is MetadataEvent ||
note.event is ContactListEvent ||
note.event is AppSpecificDataEvent
)
/**
* Tag names whose values should not match text searches: the `client` tag
* names the app that published the event (searching for "Amethyst" would
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
* values are ids or descriptions of other events, not content of this one.
*/
private val excludedTagNamesFromSearch =
setOf(
ClientTag.TAG_NAME,
PTag.TAG_NAME,
ETag.TAG_NAME,
ATag.TAG_NAME,
AltTag.TAG_NAME,
)
fun findNotesStartingWith(
text: String,
hiddenUsers: HiddenUsersState,
): List<Note> {
checkNotInMainThread()
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
val note = cache.getNoteIfExists(key)
val noteEvent = note?.event
val newNote =
if (noteEvent is AddressableEvent) {
val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address())
if (addressableNote?.event?.id == note.idHex) {
addressableNote
} else {
note
}
} else {
note
}
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
return listOfNotNull(newNote)
}
}
return cache.notes.filter { _, note ->
if (note.event is AddressableEvent) {
return@filter false
}
if (excludeNoteEventFromSearchResults(note)) {
return@filter false
}
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
note.idHex.startsWith(text, true)
) {
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
}
if (note.event?.isContentEncoded() == false) {
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
note.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
} +
cache.addressables.filter { _, addressable ->
if (excludeNoteEventFromSearchResults(addressable)) {
return@filter false
}
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
addressable.idHex.startsWith(text, true)
) {
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
}
if (addressable.event?.isContentEncoded() == false) {
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
addressable.event?.content?.contains(text, true) ?: false
} else {
false
}
}
return@filter false
}
}
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
if (text.isBlank()) return emptyList()
val key = decodeEventIdAsHexOrNull(text)
if (key != null) {
cache.getPublicChatChannelIfExists(key)?.let {
return listOf(it)
}
}
return cache.publicChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
if (text.isBlank()) return emptyList()
return cache.ephemeralChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
if (text.isBlank()) return emptyList()
try {
val parsed = Nip19Parser.uriToRoute(text)?.entity
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
return listOf(cache.getOrCreateLiveChannel(parsed.address()))
}
} catch (e: Exception) {
if (e is CancellationException) throw e
}
return cache.liveChatChannels.filter { _, channel ->
channel.anyNameStartsWith(text)
}
}
}
@@ -18,8 +18,20 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
object UnknownTopNavPerRelayFilterSet : IFeedTopNavPerRelayFilterSet
/**
* The minimal get-or-create surface of the event cache, used by callers (like
* `NewMessageTagger`) that resolve user/note references while composing without
* needing the full [LocalCache] API.
*/
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
fun getOrCreateAddressableNote(address: Address): AddressableNote?
}
@@ -0,0 +1,431 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
/**
* The sign-and-publish choke point for an [Account]: computes the relay set an
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
* relays, broadcast lists, DM inboxes) and owns every publish path - automatic,
* outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast.
*
* Feature orchestration on [Account] (and the Account*Actions classes) should
* funnel every publish through this class instead of calling the relay client
* directly.
*/
class EventBroadcaster(
private val account: Account,
) {
private fun computeRelayListForLinkedUser(user: User): Set<NormalizedRelayUrl> =
if (user == account.userProfile()) {
account.notificationRelays.flow.value
} else {
user.inboxRelays()?.ifEmpty { null }?.toSet()
?: (
account.cache.relayHints
.hintsForKey(user.pubkeyHex)
.toSet() + user.allUsedRelays()
)
}
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
if (pubkey == account.userProfile().pubkeyHex) {
account.notificationRelays.flow.value
} else {
account.cache
.getUserIfExists(pubkey)
?.inboxRelays()
?.ifEmpty { null }
?.toSet()
?: account.cache.relayHints
.hintsForKey(pubkey)
.toSet()
}
private fun computeRelaysForChannels(event: Event): Set<NormalizedRelayUrl> = account.cache.getAnyChannel(event)?.relays() ?: emptySet()
// Personal events the user stores just for themselves — drafts, app settings, bookmark
// lists — and channel/community events that already declare their own home relays
// should not be replicated to the user's broadcasting relays. Channel/community events
// that don't define any home relays fall through to broadcast, since there's nowhere
// else for them to land.
private fun wantsBroadcastRelays(event: Event): Boolean {
if (event is DraftWrapEvent ||
event is AppSpecificDataEvent ||
event is BookmarkListEvent ||
event is OldBookmarkListEvent ||
event is LabeledBookmarkListEvent
) {
return false
}
if (event is PollEvent && event.relays().isNotEmpty()) return false
if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false
if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false
val channelRelays = account.cache.getAnyChannel(event)?.relays()
if (channelRelays != null && channelRelays.isNotEmpty()) return false
return true
}
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
private fun computeRelayListToBroadcast(
event: Event,
visited: MutableSet<HexKey>,
): Set<NormalizedRelayUrl> {
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
if (!visited.add(event.id)) return emptySet()
if (event is GiftWrapEvent) {
val receiver = event.recipientPubKey()
return if (receiver != null) {
val relayList =
account.cache
.getOrCreateUser(receiver)
.dmInboxRelayList()
?.relays()
?.ifEmpty { null }
relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
} else {
emptySet()
}
}
// Seals, inner DM messages, and unsigned rumors never get broadcast
// relays: they only travel inside gift wraps.
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
return emptySet()
}
val includeBroadcast = wantsBroadcastRelays(event)
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
// everywhere
return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays
}
val relayList = mutableSetOf<NormalizedRelayUrl>()
relayList.addAll(broadcastRelays)
val author = account.cache.getUserIfExists(event.pubKey)
if (author != null) {
if (author == account.userProfile()) {
if (includeBroadcast) {
relayList.addAll(account.outboxRelays.flow.value)
} else {
// account.outboxRelays mixes in the broadcast list; for personal/channel events
// we want the user's NIP-65 / private / local outbox without it.
relayList.addAll(account.nip65RelayList.outboxFlow.value)
relayList.addAll(account.privateStorageRelayList.flow.value)
relayList.addAll(account.localRelayList.flow.value)
}
} else {
val relays =
author.outboxRelays()?.ifEmpty { null }
?: author.allUsedRelaysOrNull()
?: account.cache.relayHints.hintsForKey(author.pubkeyHex)
relayList.addAll(relays)
}
} else {
relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey))
}
if (event is PubKeyHintProvider) {
event.pubKeyHints().forEach {
relayList.add(it.relay)
}
event.linkedPubKeys().forEach { pubkey ->
relayList.addAll(computeRelayListForLinkedUser(pubkey))
}
}
if (event is EventHintProvider) {
event.eventHints().forEach {
relayList.add(it.relay)
}
event.linkedEventIds().forEach { eventId ->
account.cache.getNoteIfExists(eventId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is AddressHintProvider) {
event.addressHints().forEach {
relayList.add(it.relay)
}
event.linkedAddressIds().forEach { addressId ->
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
val linkedNoteAuthor = linkedNote.author
if (linkedNoteAuthor != null) {
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
} else {
relayList.addAll(linkedNote.relays.toSet())
}
linkedNote.event?.let { linkedEvent ->
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
}
}
}
}
if (event is PollEvent) {
relayList.addAll(event.relays())
}
if (event is MeetingSpaceEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is MeetingRoomEvent) {
relayList.addAll(event.allRelayUrls())
}
if (event is LiveActivitiesEvent) {
relayList.addAll(event.allRelayUrls())
}
relayList.addAll(computeRelaysForChannels(event))
return relayList
}
fun computeRelayListToBroadcast(note: Note): Set<NormalizedRelayUrl> {
val noteEvent = note.event
return if (noteEvent != null) {
computeRelayListToBroadcast(noteEvent)
} else {
note.relays.toSet()
}
}
suspend fun broadcast(note: Note) {
note.event?.let { noteEvent ->
val host = note.rumorHost
if (host != null) {
// Rumors are rebroadcast as their delivering envelope: the
// cached copy is content-stripped, so download it and send it.
// A just-sent note has no relays until its self-wrap echoes
// back — fall back to our own DM inbox relays. Bare seals
// (kind 13) carry no p tag, so that filter is wrap-only.
val relays =
note.relays.ifEmpty {
account.dmRelays.flow.value
.toList()
}
val filter =
if (host.kind == SealedRumorEvent.KIND) {
Filter(
kinds = listOf(host.kind),
ids = listOf(host.id),
)
} else {
Filter(
kinds = listOf(host.kind),
tags = mapOf("p" to listOf(account.pubKey)),
ids = listOf(host.id),
)
}
account.client
.fetchFirst(
filters = relays.associateWith { _ -> listOf(filter) },
)?.let { downloadedEvent ->
val toRelays = computeRelayListToBroadcast(downloadedEvent)
account.client.publish(downloadedEvent, toRelays)
}
} else if (noteEvent.sig.isEmpty()) {
// Rumor with no known wrap: publishing it would disclose the
// private content to relays even though they reject the
// missing signature.
return
} else {
account.client.publish(noteEvent, computeRelayListToBroadcast(note))
}
}
}
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
fun sendAutomatic(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, computeRelayListToBroadcast(event))
}
fun sendMyPublicAndPrivateOutbox(event: Event?) {
if (event == null) return
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, account.outboxRelays.flow.value)
}
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
events.forEach {
account.client.publish(it, account.outboxRelays.flow.value)
account.cache.justConsumeMyOwnEvent(it)
}
}
fun sendLiterallyEverywhere(event: Event) {
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
account.cache.justConsumeMyOwnEvent(event)
}
suspend fun <T : Event> signAndSendPrivately(
template: EventTemplate<T>,
relayList: Set<NormalizedRelayUrl>,
) {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relayList)
}
/**
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
* identity that is deliberately NOT this account's key) and publish to exactly
* [relayList]. Used by geohash location chat, where authorship inside a cell
* must not be linkable to the user's npub.
*/
suspend fun <T : Event> signWithAndSendPrivately(
template: EventTemplate<T>,
signer: NostrSigner,
relayList: Set<NormalizedRelayUrl>,
): T {
val event = signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
if (relayList.isNotEmpty()) account.client.publish(event, relayList)
return event
}
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
template: EventTemplate<T>,
relayList: (T) -> List<NormalizedRelayUrl>?,
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val relays = relayList(event)
val targets =
if (!relays.isNullOrEmpty()) {
relays.toSet()
} else {
computeRelayListToBroadcast(event)
}
account.chatDeliveryTracker.trackPublic(event.id, targets)
account.client.publish(event, targets)
return event
}
suspend fun <T : Event> signAndComputeBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
): T {
val event = account.signer.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
suspend fun <T : Event> signAnonymouslyAndBroadcast(
template: EventTemplate<T>,
broadcast: List<Event> = emptyList(),
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
): T {
val event = anonymousSigner.sign(template)
account.cache.justConsumeMyOwnEvent(event)
val note =
if (event is AddressableEvent) {
account.cache.getOrCreateAddressableNote(event.address())
} else {
account.cache.getOrCreateNote(event.id)
}
val relayList = computeRelayListToBroadcast(note)
account.client.publish(event, relayList)
broadcast.forEach { account.client.publish(it, relayList) }
return event
}
fun republishEventsTo(
events: List<Event>,
relays: Set<NormalizedRelayUrl>,
) {
if (relays.isEmpty() || events.isEmpty()) return
events.forEach { account.client.publish(it, relays) }
}
}
File diff suppressed because it is too large Load Diff
@@ -59,7 +59,6 @@ import java.io.File
class AccountCacheState(
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
val contentResolverFn: () -> ContentResolver,
@@ -266,7 +265,6 @@ class AccountCacheState(
signer = signerWithClientTag,
geolocationFlow = geolocationFlow,
nwcFilterAssembler = nwcFilterAssembler,
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
okHttpClientForMoney = okHttpClientForMoney,
otsResolverBuilder = otsResolverBuilder,
@@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -102,7 +100,6 @@ class CashuWalletState(
private val signer: NostrSigner,
private val cache: LocalCache,
private val scope: CoroutineScope,
private val assembler: CashuWalletFilterAssembler,
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
@@ -391,7 +388,6 @@ class CashuWalletState(
// Lifecycle
// ============================================================
private val jobs = mutableListOf<Job>()
private var currentSubscription: CashuWalletQueryState? = null
@Volatile private var started = false
@@ -469,21 +465,11 @@ class CashuWalletState(
// our own kind:10019 — and another client may have published that
// with relays unrelated to our NIP-65 lists — so we listen on the
// union of those plus our NIP-65 inbox + DM relays.
jobs +=
scope.launch(Dispatchers.IO) {
combine(
outboxRelaysFlow,
inboxRelaysFlow,
dmRelaysFlow,
_nutzapInfoEvent,
) { outbox, inbox, dm, info ->
CashuWalletQueryState(
pubkey = pubKey,
ownEventRelays = outbox,
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
)
}.collect { syncSubscription(it) }
}
// The relay subscription for this wallet is NOT here. It lives in
// CashuWalletEoseManager, inside the account-level assembler group, so it mounts and
// unmounts with every other account-level loader instead of running for the whole life of
// the Account object. What stays below is wallet *state*: indexing what arrives, and the
// local bookkeeping around it.
// Reactive incremental update: any new event arrival that matches our
// pubkey + the NIP-60/61 kinds we care about gets indexed.
@@ -538,25 +524,6 @@ class CashuWalletState(
fun destroy() {
jobs.forEach { it.cancel() }
jobs.clear()
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
}
// ============================================================
// Subscription management
// ============================================================
private fun syncSubscription(next: CashuWalletQueryState) {
val previous = currentSubscription
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = null
return
}
if (previous == next) return // unchanged
previous?.let { runCatching { assembler.unsubscribe(it) } }
currentSubscription = next
assembler.subscribe(next)
}
// ============================================================
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,8 +20,9 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,11 +21,11 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -21,10 +21,10 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.compute50kmRange
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.service.location.LocationState
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.global
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.relay
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
@@ -279,7 +279,7 @@ class AccountNappletGateways(
}
val result = CompletableDeferred<String?>()
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
when (response) {
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
tlvRecords = tlvRecords,
)
account.sendNwcRequest(request) { response: Response? ->
account.zaps.sendNwcRequest(request) { response: Response? ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
try {
val nostrRequest =
if (asZap && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = null,
message = message,
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
is PaymentSource.Nwc -> {
var done = 0
payables.forEach { payable ->
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
if (response is IErrorResponseLike) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
val canBolt12 =
account.settings.nwcWallets.value
.isNotEmpty() &&
account.defaultWalletSupportsBolt12Pay()
account.zaps.defaultWalletSupportsBolt12Pay()
val bolt12Recipients =
unverifiedZapsToSend.mapNotNull {
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
account.createZapRequestFor(
account.zaps.createZapRequestFor(
event = noteEvent,
pollOption = pollOption,
message = message,
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
return mapNotNullAsync(
items = payables,
runRequestFor = { payable: Payable ->
account.sendZapPaymentRequestFor(
account.zaps.sendZapPaymentRequestFor(
bolt11 = payable.invoice,
zappedNote = note,
onResponse = { response ->
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
account.sendBolt12Zap(
account.zaps.sendBolt12Zap(
zappedEvent = note.event,
recipientPubKey = recipient.user.pubkeyHex,
offer = recipient.offer,
@@ -54,21 +54,21 @@ class MemoryTrimmingService(
) {
// Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are
// not currently held by the UI, so it is safe and inexpensive at any pressure level.
cache.cleanMemory()
cache.cleanObservers()
cache.pruneExpiredEvents()
cache.prunePastVersionsOfReplaceables()
cache.pruner.cleanMemory()
cache.pruner.cleanObservers()
cache.pruner.pruneExpiredEvents()
cache.pruner.prunePastVersionsOfReplaceables()
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
// messages, and unobserved reactions.
account.forEach {
cache.pruneHiddenEvents(it)
cache.pruneHiddenMessages(it)
cache.pruner.pruneHiddenEvents(it)
cache.pruner.pruneHiddenMessages(it)
}
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
cache.pruneOldMessages()
cache.pruneRepliesAndReactions(accounts)
cache.pruner.pruneOldMessages()
cache.pruner.pruneRepliesAndReactions(accounts)
}
}
@@ -21,56 +21,137 @@
package com.vitorpamplona.amethyst.service.location
import android.annotation.SuppressLint
import android.content.Context
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager
import android.os.Build
import android.os.Looper
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.launch
/**
* Wraps [LocationManager] update registration as a cold [Flow].
*
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
* on every provider the device reports. The previous shotgun cost four
* simultaneous registrations — passive, network, fused and gps, the last at
* HIGH_ACCURACY — to produce a 5 km geohash.
*
* Takes a [LocationManager] rather than a `Context` so the registration
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
*
* [onListening] is fired from inside the flow, after a registration succeeds, and
* released again from the `try`/`finally` that wraps everything after it, never
* as an `onStart`/`onCompletion` pair on the returned flow. The distinction
* matters: an `onStart` fires on collection even when nothing registered, so a
* device with no usable provider would accrue location time with no location
* running, and — because the ledger refcounts the two [LocationState] flows
* together — the unpaired close would steal the other flow's holder.
*
* The pair is kept honest from both ends. The acquire cannot fire without a
* registration, because a failure to register throws before reaching it. The
* release cannot be skipped, because everything after the acquire runs inside a
* `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation]
* (the seed sweep below) can throw a non-cancellation exception and unwind
* before `awaitClose` is ever reached, and `try`/`finally` is what still runs
* the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in
* `LocationFlowTest`. (In principle a collector cancelling mid-seed would also
* unwind past `awaitClose` the same way, but that path could not be
* reproduced — `callbackFlow`'s buffer is empty at this point, so the single
* seed send returns without suspending and never observes the cancellation.)
*/
class LocationFlow(
private val context: Context,
private val locationManager: LocationManager,
private val sdkInt: Int = Build.VERSION.SDK_INT,
) {
@SuppressLint("MissingPermission")
fun get(
minTimeMs: Long = MIN_TIME,
minDistanceM: Float = MIN_DISTANCE,
minTimeMs: Long,
minDistanceM: Float,
onListening: ((Boolean) -> Unit)? = null,
): Flow<Location> =
callbackFlow {
Log.i("LocationFlow", "Start")
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
val locationCallback =
LocationListener { location ->
Log.d("LocationFlow") { "onLocationChanged $location" }
launch { send(location) }
}
locationManager.allProviders.forEach {
val location = locationManager.getLastKnownLocation(it)
Log.d("LocationFlow") { "Last Known location is $location" }
if (location != null) {
send(location)
}
Log.d("LocationFlow", "Requesting Updates")
locationManager.requestLocationUpdates(
it,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
}
// One binder call, reused for both the ladder filter and the seed.
val providers = locationManager.allProviders
awaitClose {
Log.i("LocationFlow", "Stop")
val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers }
val registered =
candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) }
?: throw SecurityException("No usable location provider. Candidates: $candidates")
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
onListening?.invoke(true)
// Cleanup lives in this finally, not in awaitClose — see the class
// KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in
// LocationFlowTest, which fails if it moves.
try {
// Seeded after registration so the no-provider path throws
// without having emitted anything; seeding first would show the
// consumer Success -> LackPermission on a device with no
// compatible provider.
freshestLastKnownLocation(candidates)?.let {
Log.d("LocationFlow") { "Last known location is $it" }
send(it)
}
awaitClose { }
} finally {
Log.i("LocationFlow") { "Stopped listening on $registered" }
locationManager.removeUpdates(locationCallback)
onListening?.invoke(false)
}
}
/** True when the registration was accepted; false when the provider refused it. */
@SuppressLint("MissingPermission")
private fun requestUpdates(
provider: String,
minTimeMs: Long,
minDistanceM: Float,
locationCallback: LocationListener,
): Boolean =
try {
locationManager.requestLocationUpdates(
provider,
minTimeMs,
minDistanceM,
locationCallback,
Looper.getMainLooper(),
)
true
} catch (e: SecurityException) {
Log.w("LocationFlow", "Provider $provider refused the update request", e)
false
}
/**
* The freshest cached fix across the providers the ladder deemed usable.
* Sweeping every provider the device reports instead would, on the
* coarse-only legacy path, pay a guaranteed-to-throw binder call per
* fine-only provider on every flow start. Still guarded per provider, like
* the update request is — on a device where one refuses us, the others
* should still seed.
*/
@SuppressLint("MissingPermission")
private fun freshestLastKnownLocation(providers: List<String>): Location? =
providers
.mapNotNull { provider ->
try {
locationManager.getLastKnownLocation(provider)
} catch (e: SecurityException) {
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
null
}
}.maxByOrNull { it.time }
}
@@ -0,0 +1,72 @@
/*
* 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.location.LocationManager
import android.os.Build
/**
* Picks which location providers to try, in order.
*
* Deliberately selects on **provider existence**, never on
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
* goes live by itself when the user enables location — including from the
* quick-settings shade without leaving the app, which is exactly what someone
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
* once at subscription start would lose that.
*
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
* lets a coarse-only app request any provider and receive a fuzzed result, is an
* Android 12 change. Amethyst declares coarse only, so the legacy branch is
* unconditional below API 31.
*
* Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the
* branch below has no permission input, so pre-31 devices would keep getting
* `network` alone and silently lose the precision the new permission was granted
* for. Whoever adds it must widen the condition here too.
*
* Returns the ordered candidate list rather than a single choice so the caller
* can fall through to the next rung if a registration is refused. An empty list
* means no compatible provider exists.
*/
object LocationProviderLadder {
// Compile-time String constants, inlined by the compiler, so naming
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
private val FULL_LADDER =
listOf(
LocationManager.FUSED_PROVIDER,
LocationManager.NETWORK_PROVIDER,
LocationManager.GPS_PROVIDER,
LocationManager.PASSIVE_PROVIDER,
)
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
fun chooseProviders(
sdkInt: Int,
exists: (String) -> Boolean,
): List<String> {
val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER
return ladder.filter(exists)
}
}
@@ -21,32 +21,83 @@
package com.vitorpamplona.amethyst.service.location
import android.content.Context
import android.location.Location
import android.location.LocationManager
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest
// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same
// package, so it needs no import.
/**
* Turns the device's location into geohashes, listening **only while the app is
* in the foreground**.
*
* The gate is not an optimisation of last resort: `Account` builds 30
* `SharingStarted.Eagerly` top-nav filter states on the account scope, and
* `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so
* without it every user with location permission holds a registration for the
* life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`.
*
* Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60
* call sites read `account.live*FollowLists.value` synchronously rather than
* collecting, and would silently serve a stale or initial value.
*/
class LocationState(
context: Context,
scope: CoroutineScope,
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
private val scope: CoroutineScope,
private val isForeground: StateFlow<Boolean>,
/**
* Resource-ledger hook: true while location updates are actively
* registered. Reaches the OS only through the default [locationSource],
* which hands it to [LocationFlow] — a caller that overrides
* [locationSource] (the tests do) is responsible for firing it, or not.
*/
private val onListening: ((Boolean) -> Unit)? = null,
private val locationSource: (Long, Float) -> Flow<Location> = { minTimeMs, minDistanceM ->
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
.get(minTimeMs, minDistanceM, onListening)
},
) {
companion object {
const val MIN_TIME: Long = 10000L
const val MIN_DISTANCE: Float = 100.0f
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
const val COARSE_MIN_TIME: Long = 60_000L
const val COARSE_MIN_DISTANCE: Float = 500.0f
/** Building-level geohashes need the tighter profile. */
const val PRECISE_MIN_TIME: Long = 10_000L
const val PRECISE_MIN_DISTANCE: Float = 100.0f
/**
* How long to keep listening after the last activity stops, so a
* one-second app switch doesn't destroy and rebuild the registration.
* Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis.
*/
const val BACKGROUND_GRACE_MS: Long = 5_000L
/**
* How long `stateIn` keeps the upstream alive after the last collector
* leaves, so a screen rotation or a tab switch doesn't rebuild the
* registration either.
*/
const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L
}
sealed class LocationResult {
@@ -59,9 +110,16 @@ class LocationState(
object Loading : LocationResult()
}
private enum class Gate { NoPermission, Paused, Listen }
private var hasLocationPermission = MutableStateFlow(false)
private var latestLocation: LocationResult = LocationResult.Loading
private var latestPreciseLocation: LocationResult = LocationResult.Loading
// Read by R1 below to decide whether to emit Loading, from a different
// coroutine than the onEach that writes it — hence a StateFlow rather than
// a plain field.
private val latestLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
fun setLocationPermission(newValue: Boolean) {
if (newValue != hasLocationPermission.value) {
@@ -69,36 +127,92 @@ class LocationState(
}
}
/**
* Foreground with an asymmetric delay: leaving the foreground waits out
* [BACKGROUND_GRACE_MS], returning to it is immediate.
*
* `debounce(5000)` would delay both edges, and the duration-selector
* overload that allows an asymmetric delay is `@FlowPreview`.
* `transformLatest` cancels the pending `delay` when foreground returns
* first, which is exactly the semantics wanted, with no preview opt-in.
*
* Known and harmless: [ForegroundTracker] starts at `false`, so on a
* process that starts backgrounded the first emission — and therefore the
* first gate verdict, including `LackPermission` — is delayed by
* [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process
* that starts into the foreground emits immediately, because the activity's
* `onStart` cancels the pending delay.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val geohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
}.onEach {
latestLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the flow", e)
latestLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
private val settledForeground: Flow<Boolean> =
isForeground.transformLatest { foreground ->
if (!foreground) delay(BACKGROUND_GRACE_MS)
emit(foreground)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
private val gate: Flow<Gate> =
combine(hasLocationPermission, settledForeground) { permitted, foreground ->
when {
!permitted -> Gate.NoPermission
foreground -> Gate.Listen
else -> Gate.Paused
}
}.distinctUntilChanged()
@OptIn(ExperimentalCoroutinesApi::class)
private fun buildGeohashStateFlow(
tag: String,
charsCount: Int,
minTimeMs: Long,
minDistanceM: Float,
cache: MutableStateFlow<LocationResult>,
): StateFlow<LocationResult> =
gate
.transformLatest { state ->
when (state) {
// Deliberately does NOT write to the cache. Today's code emits
// LackPermission without touching the cache, and wiping it
// here would cost a Loading emission — and so an empty-feed
// flash — on every permission flap, which is the regression R1
// exists to prevent. Consumers already see LackPermission from
// the StateFlow; the cache is internal and only decides whether
// Loading is emitted.
Gate.NoPermission -> emit(LocationResult.LackPermission)
// Emit nothing: stateIn keeps the last value, so every
// synchronous .value reader still sees the last known geohash
// while the OS registration is released.
Gate.Paused -> Unit
Gate.Listen -> {
// Only when there is nothing cached. Emitting Loading on
// every foreground return would flash the "Around Me" feed
// empty, because AroundMeFeedFlow.convert maps anything
// that is not Success to an empty geotag set.
if (cache.value !is LocationResult.Success) emit(LocationResult.Loading)
emitAll(
locationSource(minTimeMs, minDistanceM)
.map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult }
.onEach { cache.value = it }
.catch { e ->
Log.w(tag, "Exception in the flow", e)
cache.value = LocationResult.LackPermission
emit(LocationResult.LackPermission)
},
)
}
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestLocation,
)
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
val geohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "GeohashStateFlow",
charsCount = GeohashPrecision.KM_5_X_5.digits,
minTimeMs = COARSE_MIN_TIME,
minDistanceM = COARSE_MIN_DISTANCE,
cache = latestLocation,
)
}
/**
@@ -107,36 +221,19 @@ class LocationState(
* to every coarser level (a geohash is a prefix code), so one fix yields the
* whole region→building ladder. Kept separate so the coarser
* [geohashStateFlow] the "around me" feed relies on is unchanged.
*
* Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android
* fuzzes every fix to roughly a 3 km grid and this is not in fact
* building-level today. The profile is kept so the intent survives if the
* app ever requests `ACCESS_FINE_LOCATION`.
*/
@OptIn(ExperimentalCoroutinesApi::class)
val preciseGeohashStateFlow by lazy {
hasLocationPermission
.transformLatest {
if (it) {
emit(LocationResult.Loading)
val result =
LocationFlow(context)
.get(MIN_TIME, MIN_DISTANCE)
.onStart { onListening?.invoke(true) }
.onCompletion { onListening?.invoke(false) }
.map {
LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult
}.onEach {
latestPreciseLocation = it
}.catch { e ->
Log.w("GeohashStateFlow", "Exception in the precise flow", e)
latestPreciseLocation = LocationResult.LackPermission
emit(LocationResult.LackPermission)
}
emitAll(result)
} else {
emit(LocationResult.LackPermission)
}
}.stateIn(
scope,
SharingStarted.WhileSubscribed(5000),
latestPreciseLocation,
)
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
buildGeohashStateFlow(
tag = "PreciseGeohashStateFlow",
charsCount = GeohashChannelLevel.BUILDING.chars,
minTimeMs = PRECISE_MIN_TIME,
minDistanceM = PRECISE_MIN_DISTANCE,
cache = latestPreciseLocation,
)
}
}
@@ -22,13 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -45,31 +49,45 @@ import kotlinx.coroutines.launch
* L4 - BootCompletedReceiver (restart on boot)
* L5 - ServiceWatchdogManager (AlarmManager, 5-min health check)
*
* Two switches gate the system:
* It also decides **which accounts pull from relays**, which is a different question from
* whether the service runs, and the two are deliberately not gated the same way.
*
* - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the
* "Background notification service" toggle / Quick Settings tile). When off, every
* layer is torn down and nothing restarts, regardless of any account's setting —
* 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") **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.
* ## Who subscribes
*
* While the master is on, every saved writable account is kept loaded in
* [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
* addressed to any of them (delivered via open relay subscriptions) get unwrapped by
* the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no
* subscriber able to decrypt them.
* - **While a screen is up: every loaded account.** The user can switch accounts at any moment
* and expects the one they land on to be current, so all of them keep their own notifications,
* DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen.
* - **While the app is away: only the accounts that opted in**, via
* [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this
* account active in the background") or their NIP-46 signer toggle
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]).
*
* That is what the setting's name promises, and for a while it did not hold: participation gated
* subscriptions everywhere, so an account you had not opted in for showed no notifications even
* with the app open in front of you.
*
* ## Whether the service runs
*
* The five layers are a background concern, so they stay gated on **both** the global master
* ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service"
* toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off
* survives restarts) **and** at least one account having opted in. A foreground-only account must
* never start a foreground service that outlives the screen that wanted it.
*
* Every saved writable account is kept loaded in [AccountCacheState] whenever either condition
* holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get
* unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to
* decrypt them.
*/
class AlwaysOnNotificationServiceManager(
private val context: Context,
private val scope: CoroutineScope,
private val accountsCache: AccountCacheState,
private val localPreferences: LocalPreferences,
private val subscriptions: AccountSubscriptionRegistry,
/** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */
private val isForeground: StateFlow<Boolean>,
private val activePubKeyProvider: () -> HexKey?,
) {
companion object {
@@ -98,55 +116,84 @@ class AlwaysOnNotificationServiceManager(
wasEnabled = false
watchJob =
scope.launch {
localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled ->
if (!masterEnabled) {
// Global airplane mode: suppress every layer regardless of
// per-account participation, and stop keeping accounts loaded.
if (wasEnabled) {
disableServiceLayers()
wasEnabled = false
}
stopMultiAccountPreload()
return@collectLatest
}
// 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. 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 { account ->
account.settings.alwaysOnNotificationService
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer }
}
if (flags.isEmpty()) {
flowOf(false)
} else {
combine(flags) { values -> values.any { it } }
}
}.distinctUntilChanged()
.collectLatest { anyParticipating ->
if (anyParticipating) {
wasEnabled = true
enableServiceLayers()
} else if (wasEnabled) {
localPreferences
.notificationServiceEnabledFlow()
.combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground }
.collectLatest { (masterEnabled, foreground) ->
if (!masterEnabled && !foreground) {
// Nothing wants the accounts: the master is off and no screen is up.
// Suppress every layer and stop keeping accounts loaded.
if (wasEnabled) {
disableServiceLayers()
wasEnabled = false
}
stopMultiAccountPreload()
return@collectLatest
}
}
// Keep every writable account loaded — in the foreground so they can all
// pull, and with the master on so participation flags are observable and
// gift wraps can decrypt.
startMultiAccountPreload()
accountsAndParticipants()
.distinctUntilChanged()
.collectLatest { (all, participating) ->
// The rule the "keep this account active in the background" setting
// actually describes: while a screen is up, EVERY loaded account
// pulls its own notifications, DMs and gift wraps, because the user
// can switch to any of them and expects them current. The setting
// only decides which ones keep doing it once the app is away.
subscriptions.sync(if (foreground) all else participating)
// The service layers are a background concern, so they stay tied to
// the master switch and to somebody having opted in. A foreground-only
// account must not start a foreground service that outlives the screen.
//
// Edge-triggered, deliberately. This flow re-emits whenever the account
// map changes identity or the app crosses foreground — far more often
// than the old boolean did — and ServiceWatchdogManager.schedule()
// replaces its alarm with one starting `now + 5min`. Calling it on every
// emission pushed the watchdog's first fire past every screen-on, so the
// layer that exists to restart a dead service would never have run.
val shouldRun = masterEnabled && participating.isNotEmpty()
if (shouldRun != wasEnabled) {
if (shouldRun) enableServiceLayers() else disableServiceLayers()
wasEnabled = shouldRun
}
}
}
}
}
/**
* Every loaded account paired with the subset that opted into running in the background.
*
* Both come from one flow because they change together and the two decisions below — who
* subscribes, and whether the service runs — must never be made from different snapshots.
*/
@OptIn(ExperimentalCoroutinesApi::class)
private fun accountsAndParticipants(): Flow<Pair<List<Account>, List<Account>>> =
accountsCache.accounts.flatMapLatest { accounts ->
val all = accounts.values.toList()
val flags =
all.map { account ->
account.settings.alwaysOnNotificationService
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer ->
if (alwaysOn || signer) account else null
}
}
if (flags.isEmpty()) {
flowOf(all to emptyList())
} else {
combine(flags) { values -> all to values.filterNotNull() }
}
}
fun stop() {
watchJob?.cancel()
watchJob = null
preloadJob?.cancel()
preloadJob = null
stopMultiAccountPreload()
// Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm
// and periodic worker stay scheduled and would resurrect the service for a
// logged-out user (nobody participating).
@@ -211,10 +258,15 @@ class AlwaysOnNotificationServiceManager(
* Cancels the preload collector and releases every cached account except the
* currently active one, so users with the master off return to single-account
* memory/battery footprint.
*
* Unmounts the background subscriptions too: with the master off, the only
* account that should be talking to relays is the one on screen, and its
* subscription comes from the screen's own mount.
*/
private fun stopMultiAccountPreload() {
preloadJob?.cancel()
preloadJob = null
subscriptions.clear()
// remove this because we don't know which other accounts might be getting used.
// val active = activePubKeyProvider()
// if (active != null) {
@@ -36,6 +36,7 @@ import android.os.SystemClock
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
@@ -79,6 +80,10 @@ class NotificationRelayService : Service() {
companion object {
private const val TAG = "NotificationRelayService"
private const val CHANNEL_ID = "notification_relay_service"
/** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */
private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs"
private const val NOTIFICATION_ID = 9832
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
@@ -141,6 +146,9 @@ class NotificationRelayService : Service() {
private var relayServiceCollectorJob: Job? = null
private var connectedRelayCount = 0
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
private var lastBreakdown: List<String> = emptyList()
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
@@ -336,9 +344,12 @@ class NotificationRelayService : Service() {
pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays)
}
// Tapping goes to the screen that answers the question the notification raises — "why is it
// connected to N relays" — rather than to whatever tab was last open.
val openAppIntent =
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
data = ACTIVE_SUBSCRIPTIONS_URI.toUri()
}
val pendingIntent =
PendingIntent.getActivity(
@@ -348,11 +359,34 @@ class NotificationRelayService : Service() {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
// Expanded only. The collapsed line stays the bare count it has always been — that is all
// most people want from an ongoing notification — and the per-job breakdown appears solely
// when someone deliberately expands it to ask why the phone is talking to N relays.
//
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
// the expanded view would collapse to a single line exactly when someone is most likely
// looking at it. What each connection is *for* does not change while it is re-establishing,
// so the last known answer is still the right one; only the count above it goes stale, and
// that count is already labelled "connecting".
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.always_on_notif_title))
.setContentText(contentText)
.setSmallIcon(R.drawable.amethyst_service)
.apply {
breakdown?.let {
setStyle(
NotificationCompat
.BigTextStyle()
.setBigContentTitle(getString(R.string.always_on_notif_title))
.bigText(contentText + "\n\n" + it.joinToString("\n")),
)
}
}.setSmallIcon(R.drawable.amethyst_service)
.setContentIntent(pendingIntent)
.setOngoing(true)
.setSilent(true)
@@ -189,7 +189,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
persistOwn = false,
)
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
@@ -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.notifications
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* The per-job breakdown behind the always-on notification's relay count.
*
* **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because
* that is all most people ever want from an ongoing notification. This is for the moment someone
* taps to ask *why* their phone is talking to 40 relays.
*
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
* carry my DMs", not "how is the pool partitioned" — there is no partition.
*
* Purposes with no label — feeds and whatever screen is open — collapse into one "browsing" line.
* Those disappear on their own once the app is backgrounded, which is exactly when this notification
* matters most, so spelling them out would add noise precisely when the user is least interested.
*/
object RelayPurposeSummary {
/**
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
* the caller must then fall back to the bare count rather than render an empty section.
*/
fun lines(ctx: Context): List<String> {
val client = Amethyst.instance.client
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
val browsing = mutableSetOf<NormalizedRelayUrl>()
client.connectedRelaysFlow().value.forEach { relay ->
client
.activeRequests(relay)
.values
.flatten()
.purposes()
.forEach { purpose ->
if (SubPurposeLabels.isWorthNamingInNotification(purpose)) {
named.getOrPut(purpose) { mutableSetOf() }.add(relay)
} else {
browsing.add(relay)
}
}
}
val lines =
named.entries
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
.map { (purpose, relays) ->
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
}.toMutableList()
if (browsing.isNotEmpty()) {
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
}
return lines
}
}
@@ -0,0 +1,39 @@
/*
* 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
import com.vitorpamplona.amethyst.model.Account
/**
* A subscription query state that belongs to one logged-in account.
*
* Around 66 query-state classes already carry an `account`, but nothing tied them together, so a
* subscription manager could not ask "whose subscription is this?" without knowing the concrete
* type. That is why the Active Relay Subscriptions screen filed the home feed under "not attributed"
* despite it being built from one specific person's follow list: the base manager checked for
* `AccountQueryState` and the home feed uses `HomeQueryState`.
*
* Implement this on any query state whose subscriptions belong to a single account, and the base
* managers attribute them automatically.
*/
interface AccountScopedQuery {
val account: Account
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -114,7 +116,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
uniqueSubscribedAccounts.forEach {
val mainKey = id(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
updated.add(mainKey)
@@ -131,4 +139,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
): List<RelayBasedFilter>?
abstract fun id(key: T): U
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -127,7 +129,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
uniqueSubscribedAccounts.forEach {
val user = user(it)
val sub = findOrCreateSubFor(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
sub.updateFilters(newFilters?.groupByRelay())
updated.add(user)
}
@@ -145,4 +153,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
abstract fun user(key: T): User
abstract fun list(key: T): U
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,7 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -113,7 +115,13 @@ abstract class PerUserEoseManager<T>(
uniqueSubscribedAccounts.forEach {
val user = user(it)
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
val newFilters =
updateFilter(it, since(it))
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
@@ -131,4 +139,13 @@ abstract class PerUserEoseManager<T>(
): List<RelayBasedFilter>?
abstract fun user(key: T): User
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -66,11 +68,26 @@ abstract class SingleSubNoEoseCacheEoseManager<T>(
override fun updateSubscriptions(keys: Set<T>) {
val uniqueSubscribedAccounts = keys.distinctBy { distinct(it) }
val newFilters = updateFilter(uniqueSubscribedAccounts)?.ifEmpty { null }
val newFilters =
updateFilter(uniqueSubscribedAccounts)
?.ifEmpty { null }
// Attribute to the account that owns this subscription, once, here — rather than
// threading a pubkey through every filter builder underneath. Builders that already
// know their account keep what they set.
?.let { f -> accountPubKeyOf(uniqueSubscribedAccounts.firstOrNull())?.let { pk -> f.attributedTo(pk) } ?: f }
sub.updateFilters(newFilters?.groupByRelay())
}
abstract fun updateFilter(keys: List<T>): List<RelayBasedFilter>?
abstract fun distinct(key: T): Any
/**
* The account behind [key], when the key is account-scoped. Null for keys about other users.
*
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
* other under "not attributed" despite both being built from a single account's data.
*/
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
}
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
@@ -204,10 +203,6 @@ class RelaySubscriptionsCoordinator(
// active when the wallet's on-chain transactions screen is on top.
val onchainZaps = OnchainZapsFilterAssembler(client)
// active when a NIP-60 Cashu wallet exists for the account.
// Subscribes to kinds 17375/7375/7376/7374/10019 by author + inbound 9321 #p=self.
val cashuWallet = CashuWalletFilterAssembler(client)
// active while the user is browsing the NIP-87 mint picker. Subscribes to
// kind:38172 cashu mint announcements + kind:38000 cashu-scoped
// recommendations on the configured relay set.
@@ -279,7 +274,6 @@ class RelaySubscriptionsCoordinator(
chess,
nwc,
onchainZaps,
cashuWallet,
cashuMintDirectory,
)
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
@@ -31,17 +32,48 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01No
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
// This allows multiple screen to be listening to logged-in accounts.
//
// Carries only what an account can supply with no screen attached, so the
// background registry can mount the always-on loaders for accounts the user
// opted into keeping active while the app is away. Screens use the richer
// [AccountUiQueryState] below.
@Stable
class AccountQueryState(
val account: Account,
val feedContentStates: AccountFeedContentStates,
open class AccountQueryState(
override val account: Account,
val otherAccounts: Set<HexKey>,
)
) : AccountScopedQuery {
/**
* The feeds this account renders, when a screen is attached — null for
* background accounts.
*
* The only always-on reader is
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager],
* which reads it as a cold-start `since` floor via `lastNoteCreatedAtIfFilled()`.
* That floor only arms once the feed holds a full page, and nothing fills a
* feed that has no UI — so for a background account it could only ever
* return null anyway. Leaving the field off the background key states that
* rather than pretending there is a feed to consult.
*/
open val feedContentStates: AccountFeedContentStates? = null
}
/**
* The key for an account with a screen attached. Adds the feed states, which
* lets the notification loaders floor their cold-start queries at the depth the
* rendered feed already reaches instead of asking all-time again.
*/
@Stable
class AccountUiQueryState(
account: Account,
override val feedContentStates: AccountFeedContentStates,
otherAccounts: Set<HexKey>,
) : AccountQueryState(account, otherAccounts)
/**
* Always-on account loaders: metadata, gift wraps, drafts, inbox-relay
@@ -53,30 +85,51 @@ class AccountFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<AccountQueryState>() {
// Live tail: the recent week of gift wraps, always open at the top for new messages.
val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys)
val giftWraps = AccountGiftWrapsEoseManager(client, ::preferredKeys)
// History: older gift wraps, loaded on demand in bounded one-shot slices.
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::preferredKeys)
// Live tail: the recent week of notifications from the inbox + group host relays.
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::preferredKeys)
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys)
val group =
listOf(
AccountMetadataEoseManager(client, ::allKeys),
AccountMetadataEoseManager(client, ::preferredKeys),
giftWraps,
giftWrapsHistory,
AccountDraftsEoseManager(client, ::allKeys),
AccountDraftsEoseManager(client, ::preferredKeys),
notifications,
notificationsHistory,
// Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay.
NwcNotificationsEoseManager(client, ::allKeys),
MarmotGroupEventsEoseManager(client, ::allKeys),
NwcNotificationsEoseManager(client, ::preferredKeys),
// NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector
// inside CashuWalletState, so it starts and stops with every other account-level loader.
CashuWalletEoseManager(client, ::preferredKeys),
MarmotGroupEventsEoseManager(client, ::preferredKeys),
)
/**
* One key per account, preferring a screen's [AccountUiQueryState] over the
* background registry's key.
*
* An account can be mounted twice — the user is looking at it *and* asked to keep
* it running in the background. The managers below all dedup by user, but by
* keeping whichever key they meet first, which is just whoever mounted first.
* Resolving it here means the account being looked at keeps the feed-backed
* cold-start floor instead of losing it to a race.
*/
private fun preferredKeys(): Set<AccountQueryState> =
allKeys()
.groupBy { it.account.userProfile().pubkeyHex }
.values
.mapTo(mutableSetOf()) { keys ->
keys.firstOrNull { it.feedContentStates != null } ?: keys.first()
}
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
@@ -41,7 +41,7 @@ fun AccountFilterAssemblerSubscription(
// even if they are tracking the same tag.
val state =
remember(accountViewModel) {
AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
}
KeyDataSourceSubscription(state, dataSource)
@@ -45,7 +45,7 @@ class AccountForegroundFilterAssembler(
authenticator: IAuthStatus,
failureTracker: RelayOfflineTracker,
scope: CoroutineScope,
) : ComposeSubscriptionManager<AccountQueryState>() {
) : ComposeSubscriptionManager<AccountUiQueryState>() {
val group =
listOf(
AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys),
@@ -39,7 +39,7 @@ fun AccountForegroundFilterAssemblerSubscription(
) {
val state =
remember(accountViewModel) {
AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
@@ -0,0 +1,100 @@
/*
* 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
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.utils.Log
/**
* Mounts the account-level loaders — notifications, DMs, gift wraps, drafts,
* metadata — for accounts that have no screen of their own.
*
* Every other mount of [AccountFilterAssembler] comes from a composable holding an
* `AccountViewModel`, which means it only ever covers the account the user is
* looking at. Every other logged-in account was merely resident in memory so pushed
* gift wraps could be decrypted by their owner; everything else about them depended
* on a push message arriving. On a device with no push (no Play Services, no
* UnifiedPush distributor, Pokey not installed) they pulled nothing at all.
*
* This registry is the pull side. It holds one [AccountQueryState] per account it is
* given and drives [AccountFilterAssembler.subscribe] /
* [AccountFilterAssembler.unsubscribe] directly — those are plain functions, not
* composables, so no UI has to exist.
*
* The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no
* `otherAccounts` — populating the account switcher's avatars is a screen's job, and
* these accounts have no screen.
*
* It deliberately decides nothing about *which* accounts those are: it mounts exactly
* the set it is handed, so the foreground/background rule lives in one place, in
* [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager],
* which already watches the switches that define it and calls [sync] on every change.
*/
class AccountSubscriptionRegistry(
private val assembler: AccountFilterAssembler,
) {
companion object {
private const val TAG = "AccountSubscriptions"
}
private val mounted = mutableMapOf<HexKey, AccountQueryState>()
/**
* Makes the mounted set match [accounts] exactly: subscribes the ones that just
* joined it, unsubscribes the ones that left or were unloaded, and leaves the
* rest untouched.
*
* Idempotent, so callers can hand it the same set on every emission of the flows
* behind it without churning subscriptions.
*/
@Synchronized
fun sync(accounts: Collection<Account>) {
val wanted = accounts.associateBy { it.userProfile().pubkeyHex }
// Unmount accounts that dropped out, and accounts whose Account object was
// replaced (re-login rebuilds it) — the stale instance holds the old
// signer and relay lists, so its filters would be built from dead state.
val stale = mounted.filter { (pubkey, state) -> wanted[pubkey] !== state.account }
stale.forEach { (pubkey, state) ->
mounted.remove(pubkey)
assembler.unsubscribe(state)
}
var added = 0
wanted.forEach { (pubkey, account) ->
if (pubkey !in mounted) {
val state = AccountQueryState(account, emptySet())
mounted[pubkey] = state
assembler.subscribe(state)
added++
}
}
if (added > 0 || stale.isNotEmpty()) {
Log.d(TAG) { "Account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" }
}
}
/** Unmounts everything. Used when the app goes away with the master off, and on logout. */
@Synchronized
fun clear() = sync(emptyList())
}
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
@@ -42,7 +43,8 @@ fun filterDraftsFromKey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
kinds = DraftKinds,
authors = listOf(pubkey),
since = since,
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.BundledUpdate
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -60,7 +62,7 @@ class AccountFollowsLoaderSubAssembler(
val scope: CoroutineScope,
val authStatus: IAuthStatus,
val failureTracker: RelayOfflineTracker,
val allKeys: () -> Set<AccountQueryState>,
val allKeys: () -> Set<AccountUiQueryState>,
) : IEoseManager {
private val logTag = "AccountFollowsLoaderSubAssembler"
private val orchestrator = SubscriptionController(client)
@@ -157,6 +159,15 @@ class AccountFollowsLoaderSubAssembler(
val connectedRelays = client.connectedRelaysFlow().value
// Attributed only when one account is asking. The follow lists above are unioned across every
// logged-in account and the relays are then picked from that union, so with several accounts
// active no single one owns a given filter. Deduped by pubkey because `Account` uses identity
// equality.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried)
hasTried.removeEveryoneBut(users)
@@ -165,7 +176,13 @@ class AccountFollowsLoaderSubAssembler(
if (users.isNotEmpty()) {
RelayBasedFilter(
relay = relay,
filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()),
filter =
ExplainedFilter(
purpose = SubPurpose.RELAY_LISTS,
kinds = listOf(AdvertisedRelayListEvent.KIND),
authors = users.sorted(),
accountPubKeys = listOfNotNull(soleAccountPubKey),
),
)
} else {
null
@@ -173,7 +190,7 @@ class AccountFollowsLoaderSubAssembler(
}
}
fun updateSubscriptions(keys: Set<AccountQueryState>) {
fun updateSubscriptions(keys: Set<AccountUiQueryState>) {
val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account }
val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values)
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
@@ -89,7 +91,7 @@ class MarmotGroupEventsEoseManager(
"(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}"
}
for (relay in relaysForGroup) {
result.add(RelayBasedFilter(relay = relay, filter = filter))
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages", entityIds = listOf(groupId))))
}
}
@@ -97,7 +99,7 @@ class MarmotGroupEventsEoseManager(
if (fallbackRelays.isNotEmpty()) {
val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter()
for (relay in fallbackRelays) {
result.add(RelayBasedFilter(relay = relay, filter = ownKeyPackageFilter))
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(ownKeyPackageFilter, SubPurpose.ENCRYPTED_GROUPS, "own MLS key package")))
}
}
@@ -20,66 +20,110 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
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.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.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
/**
* Each account's own profile, lists and recent posts — for **every** logged-in account, in one
* subscription.
*
* Every filter here is `authors`-keyed, so a relay that several accounts read from can be asked
* about all of them at once by widening `authors` rather than opening a REQ per account. This was
* the largest single contributor to blowing a relay's `max_subscriptions`: seven filters in a
* subscription, repeated once per account.
*
* The per-account `limit`s are summed rather than shared. These are mostly replaceable events, so
* the limit is a safety bound rather than a page size, and scaling it by the number of accounts
* keeps each one exactly the headroom it had alone.
*/
class AccountMetadataEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
) : SingleSubEoseManager<AccountQueryState>(client, allKeys) {
override fun distinct(key: AccountQueryState) = key.account.userProfile()
fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow
override fun updateFilter(
key: AccountQueryState,
keys: List<AccountQueryState>,
since: SincePerRelayMap?,
): List<RelayBasedFilter> =
relayFlow(key).value.flatMap {
val since = since?.get(it)?.time
listOf(
filterAccountInfoAndListsFromKey(it, user(key).pubkeyHex, since),
filterFollowsAndMutesFromKey(it, user(key).pubkeyHex, since),
filterBookmarksAndReportsFromKey(it, user(key).pubkeyHex, since),
filterLastPostsFromKey(it, user(key).pubkeyHex, since ?: TimeUtils.oneMonthAgo()),
filterBasicAccountInfoFromKeys(it, key.otherAccounts.minus(key.account.userProfile().pubkeyHex).toList(), since),
).flatten()
): List<RelayBasedFilter> {
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
keys.forEach { key ->
relayFlow(key).value.forEach { relay ->
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
}
}
val userJobMap = mutableMapOf<User, List<Job>>()
return accountsPerRelay.flatMap { (relay, accounts) ->
val pubkeys = accounts.map { it.account.userProfile().pubkeyHex }
// An account joining a relay this subscription already covers must not inherit the cursor
// the earlier accounts earned — it would never ask for its own profile, follows or lists.
// This matters more here than for notifications: there is no backward pager to rescue it,
// so the account would go without until the next launch cleared the in-memory cursor.
// Drop the stored cursor AND ignore it for this pass, so the refetch does not depend on
// `since` being a live view of the map we just mutated.
val gained = authorsPerRelay.gainedAuthors(relay, pubkeys)
if (gained) clearEoseFor(relay)
val relaySince = if (gained) null else since?.get(relay)?.time
// The account-switcher avatars: other logged-in accounts this screen wants to name.
// Screens supply them; the background registry does not, so this is usually empty.
val otherAccounts = accounts.flatMapTo(mutableSetOf()) { it.otherAccounts }.minus(pubkeys.toSet())
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest {
invalidateFilters()
}
},
)
return super.newSub(key)
filterAccountInfoAndListsFromKey(relay, pubkeys, relaySince),
filterFollowsAndMutesFromKey(relay, pubkeys, relaySince),
filterBookmarksAndReportsFromKey(relay, pubkeys, relaySince),
filterLastPostsFromKey(relay, pubkeys, relaySince ?: TimeUtils.oneMonthAgo()),
filterBasicAccountInfoFromKeys(relay, otherAccounts.toList(), relaySince, pubkeys),
).flatten()
}
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
private val authorsPerRelay = MergedAuthorTracker()
/** Per-account relay watchers, reconciled as accounts come and go. See the notifications manager. */
private val userJobMap = mutableMapOf<User, List<Job>>()
override fun updateSubscriptions(keys: Set<AccountQueryState>) {
val wanted = keys.associateBy { it.account.userProfile() }
(userJobMap.keys - wanted.keys).toList().forEach { user ->
userJobMap.remove(user)?.forEach { it.cancel() }
}
wanted.forEach { (user, key) ->
if (user !in userJobMap) {
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
relayFlow(key).collectLatest { invalidateFilters() }
},
)
}
}
super.updateSubscriptions(keys)
}
override fun destroy() {
authorsPerRelay.clear()
userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } }
userJobMap.clear()
super.destroy()
}
}
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.filterContactCardsByAuthorInTheRelay
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
@@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
@@ -108,29 +109,33 @@ val AmethystMetadataTagMapFilter = mapOf("d" to listOf(APP_SPECIFIC_DATA_D_TAG))
fun filterAccountInfoAndListsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
kinds = AccountInfoAndListsFromKeyKinds,
authors = listOf(pubkey),
limit = 20,
authors = pubkeys,
limit = 20 * pubkeys.size,
since = since,
),
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
kinds = AccountInfoAndListsFromKeyKinds2,
authors = listOf(pubkey),
limit = 80,
authors = pubkeys,
limit = 80 * pubkeys.size,
since = since,
),
),
@@ -138,17 +143,19 @@ fun filterAccountInfoAndListsFromKey(
// Addressable — one card per target user — hence its own larger-limit filter.
filterContactCardsByAuthorInTheRelay(
relay = relay,
author = pubkey,
authors = pubkeys,
since = since,
),
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
kinds = AmethystMetadataKinds,
authors = listOf(pubkey),
authors = pubkeys,
tags = AmethystMetadataTagMapFilter,
limit = 1,
limit = pubkeys.size,
since = since,
),
),
@@ -20,11 +20,12 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
@@ -78,6 +79,12 @@ fun filterBasicAccountInfoFromKeys(
relay: NormalizedRelayUrl,
otherAccounts: List<HexKey>?,
since: Long?,
/**
* Who wants these profiles. The `authors` here are OTHER people — the account-switcher's
* avatars — so unlike every other builder in this package the authors are NOT the accounts
* asking, and attribution has to be passed in or the row reads as unattributed.
*/
requestedBy: List<HexKey>,
): List<RelayBasedFilter> {
if (otherAccounts.isNullOrEmpty()) return emptyList()
@@ -85,7 +92,9 @@ fun filterBasicAccountInfoFromKeys(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = requestedBy,
kinds = BasicAccountInfoKinds,
authors = otherAccounts.toList(),
limit = otherAccounts.size * BasicAccountInfoKinds.size,
@@ -95,7 +104,9 @@ fun filterBasicAccountInfoFromKeys(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = requestedBy,
kinds = BasicAccountInfoKinds2,
authors = otherAccounts.toList(),
limit = otherAccounts.size * BasicAccountInfoKinds2.size,
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip51Lists.PinListEvent
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
@@ -43,18 +44,20 @@ val ReportsAndBookmarksFromKeyKinds =
fun filterBookmarksAndReportsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
kinds = ReportsAndBookmarksFromKeyKinds,
authors = listOf(pubkey),
authors = pubkeys,
since = since,
),
),
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent
@@ -47,19 +48,21 @@ val FollowAndMutesFromKeyKinds =
fun filterFollowsAndMutesFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
kinds = FollowAndMutesFromKeyKinds,
authors = listOf(pubkey),
limit = 100,
authors = pubkeys,
limit = 100 * pubkeys.size,
since = since,
),
),
@@ -20,25 +20,28 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun filterLastPostsFromKey(
relay: NormalizedRelayUrl,
pubkey: HexKey,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
authors = listOf(pubkey),
limit = 100,
ExplainedFilter(
purpose = SubPurpose.ACCOUNT_DATA,
accountPubKeys = pubkeys,
authors = pubkeys,
limit = 100 * pubkeys.size,
since = since,
),
),
@@ -20,13 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.MergedAuthorTracker
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
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.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.NormalizedRelayUrl
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
@@ -34,96 +35,162 @@ import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* The live notification tail, for **every** logged-in account, in one subscription.
*
* Notifications are `#p`-scoped, so a relay that serves several of the user's accounts can be asked
* about all of them in one filter naming every pubkey — the query is identical in shape, only wider.
* That is what keeps this within a relay's `max_subscriptions`: one REQ per relay instead of one per
* (account, relay). With four accounts open, the per-account form pushed strfry relays past their
* 20-subscription cap and they answered `ERROR: too many concurrent REQs` — a NOTICE, carrying no
* subscription id, so the client could not even tell which REQ had been dropped. Those filters stayed
* "live" in our books and silently never delivered.
*
* Gift wraps deliberately do **not** merge this way. They are unsolicited and their content is opaque
* to the relay, so it cannot rate-limit them per recipient; a merged query would let one spammed
* account consume the shared `limit` and starve every other account's DMs. Each account keeps its own
* budget there — see [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager].
*
* ## The `limit` here is shared, and deliberately not scaled
*
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager]
* multiplies its limits by the number of merged accounts; this one does not, and the difference is the
* kind of event. Metadata is replaceable, so the limit is a safety bound and widening it costs nothing.
* Notifications are a stream, so the limit is a page size: scaling it by four accounts would ask four
* times the data of every relay on every cold start, and most would clamp it anyway (`max_limit` is 500
* on strfry — already below the 2000 the summary filter asks for).
*
* So on a cold start a busy account can crowd a quiet one out of the shared newest-N. What recovers it
* is [AccountNotificationsHistoryEoseManager], which pages backward per account and stays unmerged for
* exactly that reason.
*/
class AccountNotificationsEoseFromInboxRelaysManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
) : SingleSubEoseManager<AccountQueryState>(client, allKeys) {
override fun distinct(key: AccountQueryState) = key.account.userProfile()
/**
* Downloads most notifications from the user's own inbox relays.
* But also connects to all the follows relays to check for new notifications that are not in the user's
* own inbox.
* One filter set per inbox relay, naming every account that reads from it.
*
* The `since` floor is per relay rather than per account, because the merged filter is per relay:
* the **oldest** of the participating accounts' floors wins, so widening the query for one account
* can never cut another one short.
*/
override fun updateFilter(
key: AccountQueryState,
keys: List<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 = notificationSince,
) +
filterNotificationsToPubkey(
relay = it,
pubkey = user(key).pubkeyHex,
since = notificationSince,
)
val accountsPerRelay = mutableMapOf<NormalizedRelayUrl, MutableList<AccountQueryState>>()
keys.forEach { key ->
key.account.notificationRelays.flow.value.forEach { relay ->
accountsPerRelay.getOrPut(relay) { mutableListOf() }.add(key)
}
}
// NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested here.
// It lives on the group's host relay and used to be one `#h` filter per relay carrying every
// joined group id — but this subscription also carries the inbox filters above, which have no
// `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or multi-
// channel) filter to "global", a class that by design never receives channel-scoped events. So
// those filters answered the stored query at EOSE and then went deaf until the next launch.
//
// Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped to
// exactly one channel — RelayGroupJoinedChatTailSubAssembler and BuzzDmJoinedChatTailSubAssembler,
// both mounted app-wide from LoggedInPage, so coverage is unchanged and delivery is live.
return inbox
return accountsPerRelay.flatMap { (relay, accounts) ->
val pubkeys = accounts.map { it.account.userProfile().pubkeyHex }
// An account that joins a relay this subscription already covers would otherwise inherit
// the cursor the earlier accounts earned, and never ask for anything older than it. Drop
// the stored cursor AND ignore it for this pass — not just the former, which would leave
// the refetch depending on `since` being a live view of the map we just mutated.
val gained = authorsPerRelay.gainedAuthors(relay, pubkeys)
if (gained) clearEoseFor(relay)
// A cold-start floor, NOT paging — backward paging lives in
// [AccountNotificationsHistoryEoseManager]. Read only when a relay has no EOSE yet; once it
// does, the EOSE time wins and this is never consulted.
//
// `since` means *newer than*, so this floors the query at the depth the feed already reaches
// rather than asking all-time again. It stays null until the feed holds a full page — and a
// background account has no feed at all (no screen ever mounted one), which is why the key
// carries none and this reads null there. Null for ANY account on the relay means no floor
// at all, since a floor derived from one account's feed would truncate the others'.
val floors = accounts.map { it.feedContentStates?.notifications?.lastNoteCreatedAtIfFilled() }
val pagingBoundary = if (floors.any { it == null }) null else floors.filterNotNull().min()
// No `since` floor on the first fetch. These filters are scoped by `#p` to my own
// keys 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 = (if (gained) null else since?.get(relay)?.time) ?: pagingBoundary
// NIP-29 group activity (reactions/replies to my messages) is deliberately NOT requested
// here. It lives on the group's host relay and used to be one `#h` filter per relay carrying
// every joined group id — but this subscription also carries the inbox filters below, which
// have no `#h` at all, and `block/buzz` downgrades any subscription with a channel-less (or
// multi-channel) filter to "global", a class that by design never receives channel-scoped
// events. So those filters answered the stored query at EOSE and then went deaf until the
// next launch.
//
// Group and Buzz-DM activity now rides the per-channel subscriptions that are already scoped
// to exactly one channel — RelayGroupJoinedChatTailSubAssembler and
// BuzzDmJoinedChatTailSubAssembler, both mounted app-wide from LoggedInPage, so coverage is
// unchanged and delivery is live.
filterSummaryNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince) +
filterNotificationsToPubkeys(relay = relay, pubkeys = pubkeys, since = notificationSince)
}
}
val userJobMap = mutableMapOf<User, List<Job>>()
/**
* Per-account watchers, rebuilt as accounts come and go.
*
* There is only one subscription now, so these cannot hang off a per-key `newSub`. They are keyed
* by user and reconciled here: an account that leaves has its jobs cancelled, and one that arrives
* gets its own. Re-entrancy is safe — the watchers call `invalidateFilters()`, which lands back
* here and finds every account already watched.
*/
private val authorsPerRelay = MergedAuthorTracker()
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow.sample(1000).collectLatest {
invalidateFilters()
}
},
// No group/Buzz-DM watchers here any more: those filters moved to the per-channel
// subscriptions, which mount and unmount with the channel itself.
key.account.scope.launch(Dispatchers.IO) {
key.feedContentStates.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
},
)
override fun updateSubscriptions(keys: Set<AccountQueryState>) {
val wanted = keys.associateBy { it.account.userProfile() }
return super.newSub(key)
(userJobMap.keys - wanted.keys).toList().forEach { user ->
userJobMap.remove(user)?.forEach { it.cancel() }
}
wanted.forEach { (user, key) ->
if (user !in userJobMap) {
userJobMap[user] =
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.notificationRelays.flow.sample(1000).collectLatest {
invalidateFilters()
}
},
) +
// Only a screen can fill a feed, so there is nothing to watch for a
// background account.
listOfNotNull(
key.feedContentStates?.let { feeds ->
key.account.scope.launch(Dispatchers.IO) {
feeds.notifications.lastNoteCreatedAtWhenFullyLoaded.sample(5000).collectLatest {
invalidateFilters()
}
}
},
)
}
}
super.updateSubscriptions(keys)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
override fun destroy() {
authorsPerRelay.clear()
userJobMap.values.forEach { jobs -> jobs.forEach { it.cancel() } }
userJobMap.clear()
super.destroy()
}
}
@@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01N
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.relayClient.reqCommand.account.AccountUiQueryState
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -30,24 +30,38 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscriptio
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
class AccountNotificationsEoseFromRandomRelaysManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
allKeys: () -> Set<AccountUiQueryState>,
) : PerUserEoseManager<AccountUiQueryState>(client, allKeys) {
override fun user(key: AccountUiQueryState) = key.account.userProfile()
/**
* Downloads most notifications from the user's own inbox relays.
* But also connects to all the follows relays to check for new notifications that are not in the user's
* own inbox.
* Most notifications arrive on the user's own inbox relays. This is the straggler probe for the
* rest: other clients sometimes deliver a mention to the author's own relays instead of the
* recipient's inbox, so those only ever turn up if we go and look.
*
* It looks at a **rotating window of [RELAYS_PER_PASS] relays**, not at every relay the follows
* post to. The whole set was ~330 relays on a normal account, and subscribing to all of them
* held roughly 670 filters permanently — by a wide margin the largest thing the client ran, for
* a job that only needs to sweep the space eventually, not watch all of it at once. The window
* slides every [PASS_DURATION_MS], so every relay is still visited, just never all at the same
* time.
*
* The window is a contiguous slice of a URL-sorted list rather than a random draw: a fresh
* random pick on each invalidation would re-REQ a different set every few seconds, which costs
* more than the subscriptions it replaced. Deterministic order means the window only moves when
* the rotation timer says so.
*/
override fun updateFilter(
key: AccountQueryState,
key: AccountUiQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// only loads this after the feed is built, so it stays null on a quiet inbox. No week floor
@@ -55,16 +69,30 @@ class AccountNotificationsEoseFromRandomRelaysManager(
// 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 candidates =
(key.account.followsPerRelay.value.keys - key.account.notificationRelays.flow.value)
.sortedBy { it.url }
if (candidates.isEmpty()) return emptyList()
val start = (passIndex * RELAYS_PER_PASS).mod(candidates.size)
val window = List(minOf(RELAYS_PER_PASS, candidates.size)) { candidates[(start + it).mod(candidates.size)] }
return window.flatMap {
val since = since?.get(it)?.time ?: defaultSince
filterJustTheLatestNotificationsToPubkeyFromRandomRelays(it, user(key).pubkeyHex, since)
}
}
/**
* Which slice of the sorted relay list the current pass is on. Bumped by the rotation job below;
* `mod` at the read site keeps it valid however far it runs.
*/
@Volatile private var passIndex = 0
val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
override fun newSub(key: AccountUiQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
userJobMap[user] =
@@ -80,6 +108,14 @@ class AccountNotificationsEoseFromRandomRelaysManager(
invalidateFilters()
}
},
// Slides the window. Cancelled with the others in endSub, so it stops with the account.
key.account.scope.launch(Dispatchers.IO) {
while (isActive) {
delay(PASS_DURATION_MS)
passIndex++
invalidateFilters()
}
},
)
return super.newSub(key)
@@ -92,4 +128,15 @@ class AccountNotificationsEoseFromRandomRelaysManager(
super.endSub(key, subId)
userJobMap[key]?.forEach { it.cancel() }
}
companion object {
/**
* Relays watched per pass. Small on purpose: this is a background sweep for misdelivered
* mentions, and the inbox relays carry the real traffic.
*/
const val RELAYS_PER_PASS = 5
/** How long a window stays put before sliding. Long enough that re-REQ churn stays negligible. */
const val PASS_DURATION_MS = 5 * 60 * 1000L
}
}
@@ -22,6 +22,8 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.experimental.attestations.recommendation.AttestorRecommendationEvent
import com.vitorpamplona.quartz.experimental.attestations.request.AttestationRequestEvent
import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent
@@ -30,7 +32,6 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -154,7 +155,9 @@ fun filterNotificationsHistoryToPubkey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = AllNotificationKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = limit,
@@ -181,7 +184,9 @@ fun filterGroupNotificationsHistoryToPubkey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = limit,
@@ -191,20 +196,22 @@ fun filterGroupNotificationsHistoryToPubkey(
)
}
fun filterSummaryNotificationsToPubkey(
fun filterSummaryNotificationsToPubkeys(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = pubkeys,
kinds = SummaryKinds,
tags = mapOf("p" to listOf(pubkey)),
tags = mapOf("p" to pubkeys),
limit = 2000,
since = since,
),
@@ -212,20 +219,22 @@ fun filterSummaryNotificationsToPubkey(
)
}
fun filterNotificationsToPubkey(
fun filterNotificationsToPubkeys(
relay: NormalizedRelayUrl,
pubkey: HexKey?,
pubkeys: List<HexKey>,
since: Long?,
): List<RelayBasedFilter> {
if (pubkey.isNullOrEmpty()) return emptyList()
if (pubkeys.isEmpty()) return emptyList()
return listOf(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = pubkeys,
kinds = NotificationsPerKeyKinds,
tags = mapOf("p" to listOf(pubkey)),
tags = mapOf("p" to pubkeys),
limit = 500,
since = since,
),
@@ -233,9 +242,11 @@ fun filterNotificationsToPubkey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = pubkeys,
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
tags = mapOf("p" to pubkeys),
limit = 200,
since = since,
),
@@ -243,9 +254,11 @@ fun filterNotificationsToPubkey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = pubkeys,
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
tags = mapOf("p" to pubkeys),
limit = 10,
since = since,
),
@@ -271,7 +284,9 @@ fun filterGroupNotificationsToPubkey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = GroupNotificationKinds,
tags = mapOf("p" to listOf(pubkey), "h" to groupIds),
limit = 200,
@@ -292,7 +307,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = SummaryKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = 20,
@@ -302,7 +319,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = NotificationsPerKeyKinds,
tags = mapOf("p" to listOf(pubkey)),
limit = 20,
@@ -312,7 +331,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = NotificationsPerKeyKinds2,
tags = mapOf("p" to listOf(pubkey)),
limit = 10,
@@ -322,7 +343,9 @@ fun filterJustTheLatestNotificationsToPubkeyFromRandomRelays(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NOTIFICATIONS,
accountPubKeys = listOfNotNull(pubkey),
kinds = NotificationsPerKeyKinds3,
tags = mapOf("p" to listOf(pubkey)),
limit = 2,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
@@ -87,7 +89,8 @@ class NwcNotificationsEoseManager(
RelayBasedFilter(
relay = wallet.uri.relayUri,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.NWC,
kinds = listOf(NwcNotificationEvent.KIND, NwcNotificationEvent.LEGACY_KIND),
authors = listOf(wallet.uri.pubKeyHex),
tags = mapOf("p" to listOf(signer.pubKey)),
@@ -0,0 +1,124 @@
/*
* 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.nip60Cashu
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.cashuWalletFilters
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.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
/**
* The account's NIP-60 wallet and NIP-61 nutzap inbox.
*
* This used to run from a collector inside `CashuWalletState`, on the account's own scope, which made
* the wallet the only account-level subscription whose lifetime was decided by the model rather than
* by a mount. It ran for every [com.vitorpamplona.amethyst.model.Account] object that happened to be
* resident — including accounts loaded purely so pushed gift wraps could be decrypted, which have no
* wallet anyone is looking at — and the attempt to fix that bolted a "is this pubkey subscribed
* anywhere" flow onto the model, so a model object was reading the relay layer's bookkeeping to
* decide whether to talk to relays.
*
* As a manager in [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler]'s
* group it starts and stops with every other account-level loader: the screen's mount for the account
* on show, the registry for the rest, and whatever the foreground/background rule becomes without this
* having to know about it. Exactly how NWC already worked.
*
* Per user, never merged: each account's wallet reads its own outbox for its own events and its own
* inbox for nutzaps addressed to it, so there is no shared query to fold them into.
*/
class CashuWalletEoseManager(
client: INostrClient,
allKeys: () -> Set<AccountQueryState>,
) : PerUserEoseManager<AccountQueryState>(client, allKeys) {
override fun user(key: AccountQueryState) = key.account.userProfile()
override fun updateFilter(
key: AccountQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val account = key.account
val wallet = account.cashuWalletState
// NIP-65 outbox for our own wallet events; inbox + DM relays plus whatever our own kind:10019
// advertises for inbound nutzaps, since another client may have published that with relays
// unrelated to our NIP-65 lists.
return cashuWalletFilters(
CashuWalletQueryState(
pubkey = account.userProfile().pubkeyHex,
ownEventRelays = account.outboxRelays.flow.value,
inboxRelays =
account.notificationRelays.flow.value +
account.dmRelays.flow.value +
(wallet.nutzapInfoEvent.value?.relays() ?: emptyList()),
),
since,
)
}
private val userJobMap = mutableMapOf<User, List<Job>>()
@OptIn(FlowPreview::class)
override fun newSub(key: AccountQueryState): Subscription {
val user = user(key)
userJobMap[user]?.forEach { it.cancel() }
// The relay sets and the nutzap-info event all move the query, so each one re-invalidates.
// Sampled because a relay-list edit can land as a burst of list events.
userJobMap[user] =
listOf(
key.account.outboxRelays.flow,
key.account.notificationRelays.flow,
key.account.dmRelays.flow,
).map { flow ->
key.account.scope.launch(Dispatchers.IO) {
flow.sample(1000).collectLatest { invalidateFilters() }
}
} +
listOf(
key.account.scope.launch(Dispatchers.IO) {
key.account.cashuWalletState.nutzapInfoEvent
.sample(1000)
.collectLatest { invalidateFilters() }
},
)
return super.newSub(key)
}
override fun endSub(
key: User,
subId: String,
) {
super.endSub(key, subId)
userJobMap.remove(key)?.forEach { it.cancel() }
}
}
@@ -24,6 +24,7 @@ import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats.ChannelLoaderSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -32,8 +33,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class ChannelFinderQueryState(
val channel: Channel,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class ChannelFinderFilterAssemblyGroup(
@@ -23,9 +23,10 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28P
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -54,7 +55,9 @@ fun filterMissingChannelsById(keys: List<ChannelFinderQueryState>): List<RelayBa
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.PUBLIC_CHATS,
entityIds = channelIds.sorted(),
kinds = filterMissingPublicChannelsByIdKinds,
ids = channelIds.sorted(),
),
@@ -21,8 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent
@@ -37,7 +38,9 @@ fun filterChannelMetadataUpdatesById(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.PUBLIC_CHATS,
entityIds = channels.map { it.idHex }.sorted(),
kinds = channelMetadataKinds,
tags = mapOf("e" to channels.map { it.idHex }),
since = since,
@@ -21,8 +21,9 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip53LiveActivities
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
@@ -36,7 +37,8 @@ fun filterLiveStreamUpdatesByAddress(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.LIVE_ROOMS,
kinds = listOf(LiveActivitiesEvent.KIND),
tags = mapOf("d" to channels.map { it.address.dTag }),
authors = channels.map { it.address.pubKeyHex },
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.AddressableAuthorRelayLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.NoteEventLoaderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers.EventWatcherSubAssembler
@@ -35,8 +36,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@Stable
class EventFinderQueryState(
val note: Note,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class EventFinderFilterAssembler(
@@ -20,12 +20,13 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -105,7 +106,8 @@ fun filterMissingAddressables(missingAddressables: Map<NormalizedRelayUrl, Set<A
RelayBasedFilter(
relay = relayEntry.key,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.REFERENCED_EVENTS,
kinds = listOf(address.kind),
authors = listOf(address.pubKeyHex),
limit = 1,
@@ -115,7 +117,8 @@ fun filterMissingAddressables(missingAddressables: Map<NormalizedRelayUrl, Set<A
RelayBasedFilter(
relay = relayEntry.key,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.REFERENCED_EVENTS,
kinds = listOf(address.kind),
tags = mapOf("d" to listOf(address.dTag)),
authors = listOf(address.pubKeyHex),
@@ -21,13 +21,14 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -134,7 +135,7 @@ fun filterMissingEvents(missingEventIds: Map<NormalizedRelayUrl, Set<String>>):
if (it.value.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(ids = it.value.sorted()),
filter = ExplainedFilter(purpose = SubPurpose.REFERENCED_EVENTS, ids = it.value.sorted()),
)
} else {
null
@@ -60,6 +60,16 @@ class EventWatcherSubAssembler(
lastNotesOnFilter = keys.map { it.note }
// Attributed only when one account is watching. The notes here are whatever is rendered, not
// anything an account owns, so with several accounts active no single one is the honest owner —
// and splitting would re-request the same replies/zaps once per account.
// Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for
// the same logged-in user would look like two accounts and suppress attribution entirely.
val soleAccountPubKey =
keys
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
.singleOrNull()
return groupByRelayPresence(lastNotesOnFilter, latestEOSEs)
.map { group ->
if (group.isNotEmpty()) {
@@ -67,8 +77,8 @@ class EventWatcherSubAssembler(
val events = group.mapNotNull { if (it !is AddressableNote) it else null }
listOfNotNull(
filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs)),
filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs)),
filterRepliesAndReactionsToNotes(events, findMinimumEOSEs(events, latestEOSEs), soleAccountPubKey),
filterRepliesAndReactionsToAddresses(addressables, findMinimumEOSEs(addressables, latestEOSEs), soleAccountPubKey),
).flatten()
} else {
emptyList()
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
@@ -71,6 +73,7 @@ val TextNoteKindList = listOf(TextNoteEvent.KIND)
fun filterRepliesAndReactionsToAddresses(
keys: List<AddressableNote>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
if (keys.isEmpty()) return null
@@ -91,7 +94,9 @@ fun filterRepliesAndReactionsToAddresses(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = RepliesAndReactionsToAddressesKinds1,
tags = mapOf("a" to sortedList),
since = since,
@@ -102,7 +107,9 @@ fun filterRepliesAndReactionsToAddresses(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = PostsAndChatMessagesToAddresses,
tags = mapOf("a" to sortedList),
since = since,
@@ -113,7 +120,9 @@ fun filterRepliesAndReactionsToAddresses(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = DeletionKindList,
tags = mapOf("a" to sortedList),
since = since,
@@ -124,7 +133,9 @@ fun filterRepliesAndReactionsToAddresses(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = TextNoteKindList,
tags = mapOf("q" to sortedList),
since = since,
@@ -22,13 +22,15 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.experimental.attestations.attestation.AttestationEvent
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
@@ -77,6 +79,7 @@ val RepliesAndReactionsKinds2 =
fun filterRepliesAndReactionsToNotes(
events: List<Note>,
since: SincePerRelayMap?,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter>? {
if (events.isEmpty()) return null
@@ -98,7 +101,9 @@ fun filterRepliesAndReactionsToNotes(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = RepliesAndReactionsKinds,
tags = mapOf("e" to sortedList),
since = since,
@@ -109,7 +114,9 @@ fun filterRepliesAndReactionsToNotes(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = RepliesAndReactionsKinds2,
tags = mapOf("e" to sortedList),
since = since,
@@ -119,7 +126,9 @@ fun filterRepliesAndReactionsToNotes(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.ENGAGEMENT,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = listOf(TextNoteEvent.KIND, CommentEvent.KIND),
tags = mapOf("q" to sortedList),
since = since,
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders.UserOutboxFinderSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserCardsSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers.UserReportsSubAssembler
@@ -36,8 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineT
@Stable
class UserFinderQueryState(
val user: User,
val account: Account,
)
override val account: Account,
) : AccountScopedQuery
@Stable
class UserFinderFilterAssembler(
@@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.loaders
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows.pickRelaysToLoadUsers
@@ -101,6 +103,18 @@ class UserOutboxFinderSubAssembler(
val accounts = keys.mapTo(mutableSetOf()) { it.account }
val connectedRelays = client.connectedRelaysFlow().value
// Attributed only when one account is asking. Unlike the reports sweep, the relay choice here
// is made from the *union* of every logged-in account's tiers (`pickRelaysToLoadUsers` below),
// and the users being resolved are whoever is on screen rather than anyone's follow list — so
// with several accounts active there is no single honest owner for a given filter, and
// splitting the sweep per account would re-issue the same lookups once per account.
// Deduped by pubkey, not by `Account`: that class uses identity equality, so two objects for
// the same logged-in user would look like two accounts and suppress attribution entirely.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val perRelayKeysBoth =
pickRelaysToLoadUsers(
noOutboxList,
@@ -116,7 +130,13 @@ class UserOutboxFinderSubAssembler(
if (sortedUsers.isNotEmpty()) {
RelayBasedFilter(
relay = it.key,
filter = Filter(kinds = relayListKinds, authors = sortedUsers),
filter =
ExplainedFilter(
kinds = relayListKinds,
authors = sortedUsers,
purpose = SubPurpose.RELAY_LISTS,
accountPubKeys = listOfNotNull(soleAccountPubKey),
),
)
} else {
null
@@ -146,7 +166,14 @@ class UserOutboxFinderSubAssembler(
fallbackRelays.map { relay ->
RelayBasedFilter(
relay = relay,
filter = Filter(kinds = relayListKinds, authors = sortedAbandoned),
filter =
ExplainedFilter(
kinds = relayListKinds,
authors = sortedAbandoned,
purpose = SubPurpose.RELAY_LISTS,
purposeDetail = "outbox discovery for users whose relay list we lost",
accountPubKeys = listOfNotNull(soleAccountPubKey),
),
)
}
@@ -20,9 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -33,14 +34,17 @@ fun filterReportsToKeysFromTrusted(
trustedAccounts: List<HexKey>,
relay: NormalizedRelayUrl,
since: Long?,
accountPubKey: HexKey? = null,
): RelayBasedFilter? {
if (targets.isEmpty() || trustedAccounts.isEmpty()) return null
return RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.MODERATION,
kinds = ReportKindList,
authors = trustedAccounts,
accountPubKeys = listOfNotNull(accountPubKey),
tags = mapOf("p" to targets.sorted()),
since = since,
),
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
@@ -28,7 +30,6 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
@@ -62,6 +63,7 @@ fun filterUserMetadataForKey(
indexRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
since: EOSEAccountFast<User>,
accountPubKey: HexKey? = null,
): List<RelayBasedFilter> {
val perRelayUsers =
mapOfSet {
@@ -111,7 +113,9 @@ fun filterUserMetadataForKey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = UserMetadataForKeyKinds,
authors = firstTimers.sorted(),
),
@@ -123,7 +127,9 @@ fun filterUserMetadataForKey(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.PROFILE_METADATA,
accountPubKeys = listOfNotNull(accountPubKey),
kinds = UserMetadataForKeyKinds,
authors = updates.sorted(),
since = minimumTime,
@@ -70,6 +70,13 @@ class UserCardsSubAssembler(
val accounts = keys.mapTo(mutableSetOf()) { it.account }
// Attributed only when one account is asking: the trusted-author sets below are pooled across
// accounts, so with several active none of them owns a given filter.
val soleAccountPubKey =
accounts
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
.singleOrNull()
val trustedAccounts: Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
accounts.forEach { account ->
@@ -77,9 +84,14 @@ class UserCardsSubAssembler(
add(it, account.userProfile().pubkeyHex)
}
}
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { account ->
if (account != null) {
add(account.relayUrl, account.pubkey)
accounts.map { it.trustProviderList.liveUserRankProvider.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
accounts.map { it.trustProviderList.liveUserFollowerCount.value }.forEach { provider ->
if (provider != null) {
add(provider.relayUrl, provider.pubkey)
}
}
}
@@ -92,12 +104,14 @@ class UserCardsSubAssembler(
val trustedAccounts = trustedUsersInThisRelay.sorted()
listOfNotNull(
filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
accountPubKey = soleAccountPubKey,
targets = groups.usersWithoutEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
since = null,
),
filterContactCardsToTargetKeysFromTrustedAccountsInTheRelay(
accountPubKey = soleAccountPubKey,
targets = groups.usersWithEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.watchers
import com.vitorpamplona.amethyst.commons.model.toHexSet
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
@@ -31,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
class UserReportsSubAssembler(
client: INostrClient,
@@ -65,14 +65,19 @@ class UserReportsSubAssembler(
if (lastUsersOnFilter.isEmpty()) return null
val trustedAccountsPerRelay =
mapOfSet {
accounts.map { it.declaredFollowsPerOutboxRelay.value }.forEach {
add(it)
}
}
// One pass per account, never a union. "Whose follows wrote this report" is the whole point of
// the filter, so merging every logged-in account's follow list into one per-relay map both made
// the result unattributable and asked one account's follows of another account's outbox relays.
return accounts.flatMap { account -> filtersFor(account, lastUsersOnFilter) }.ifEmpty { null }
}
return trustedAccountsPerRelay
private fun filtersFor(
account: Account,
lastUsersOnFilter: Set<User>,
): List<RelayBasedFilter> {
val accountPubKey = account.userProfile().pubkeyHex
return account.declaredFollowsPerOutboxRelay.value
.flatMap { (relay, trustedUsersInThisRelay) ->
// this relay + accounts are where we could find reports.
// we might have already loaded them, so let's separate new targets that were checked before from the others
@@ -84,12 +89,14 @@ class UserReportsSubAssembler(
trustedAccounts = trustedAccounts,
relay = relay,
since = null,
accountPubKey = accountPubKey,
),
filterReportsToKeysFromTrusted(
targets = groups.usersWithEose.toHexSet(),
trustedAccounts = trustedAccounts,
relay = relay,
since = findMinimumEOSEsForUsers(groups.usersWithEose, relay),
accountPubKey = accountPubKey,
),
)
}
@@ -96,6 +96,13 @@ class UserWatcherSubAssembler(
}
// assembles all index relays from all accounts
// Attributed only when one account is looking: the index relays below are pooled across every
// account and the users are whoever is on screen, so with several askers none of them owns it.
val soleAccountPubKey =
keys
.mapTo(mutableSetOf()) { it.account.userProfile().pubkeyHex }
.singleOrNull()
val indexRelays = mutableSetOf<NormalizedRelayUrl>()
keys.mapTo(mutableSetOf()) { it.account }.forEach {
indexRelays.addAll(
@@ -110,6 +117,7 @@ class UserWatcherSubAssembler(
indexRelays,
failureTracker.cannotConnectRelays,
latestEOSEs,
soleAccountPubKey,
).ifEmpty { null }
sub.updateFilters(newFilters?.groupByRelay())
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.MutableQueryState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchPostWatcherSubAssembler
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies.SearchUserWatcherSubAssembler
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
@@ -35,8 +36,9 @@ import kotlinx.coroutines.flow.MutableStateFlow
@Stable
class SearchQueryState(
val searchQuery: MutableStateFlow<String>,
val account: Account,
) : MutableQueryState {
override val account: Account,
) : MutableQueryState,
AccountScopedQuery {
override fun flow(): Flow<String> = searchQuery
}
@@ -20,11 +20,12 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
@@ -53,7 +54,8 @@ fun filterByAuthor(
RelayBasedFilter(
relay = it.key,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.SEARCH,
kinds = MetadataKindList,
authors = myUser,
),
@@ -20,10 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
fun searchPeopleByName(
@@ -33,7 +34,8 @@ fun searchPeopleByName(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.SEARCH,
kinds = listOf(MetadataEvent.KIND),
search = searchString,
limit = 1000,
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent
import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent
import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent
@@ -31,7 +33,6 @@ import com.vitorpamplona.quartz.experimental.nns.NNSEvent
import com.vitorpamplona.quartz.experimental.zapPolls.ZapPollEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
@@ -112,7 +113,8 @@ fun searchPostsByText(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.SEARCH,
kinds = SearchPostsByTextKinds1,
search = searchString,
limit = 100,
@@ -121,7 +123,8 @@ fun searchPostsByText(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.SEARCH,
kinds = SearchPostsByTextKinds2,
search = searchString,
limit = 100,
@@ -130,7 +133,8 @@ fun searchPostsByText(
RelayBasedFilter(
relay = relay,
filter =
Filter(
ExplainedFilter(
purpose = SubPurpose.SEARCH,
kinds = SearchPostsByTextKinds3,
search = searchString,
limit = 100,
@@ -0,0 +1,64 @@
/*
* 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.resourceusage
/**
* Refcounts a boolean session so overlapping holders don't close each other's
* segment. [LocationState][com.vitorpamplona.amethyst.service.location.LocationState]
* exposes two independent location flows that can both be listening at once —
* the "Around Me" feed plus an open geohash chat — and a bare
* [SessionTimeIntegrator] would close the segment when either one stops.
*
* The count and the transition it drives are taken under one lock. An
* [java.util.concurrent.atomic.AtomicInteger] beside an unsynchronised call is
* not enough: two threads can leave the counter at 1 while the last
* `setActive(false)` lands after the `setActive(true)`, latching the session
* off with a holder still active.
*
* Takes the setter as a lambda rather than a [SessionTimeIntegrator] because
* that is all it needs, and so tests need no accountant or store file.
*
* Reports **transitions only**, not every call — the contract the name implies,
* and what keeps the class honest for a future caller that reacts to the
* callback rather than integrating it. (For [SessionTimeIntegrator] specifically
* a 1 -> 2 re-entry would have been harmless: it splits one segment into two
* contiguous pieces that `account()` re-adds, and its starts increment is
* already guarded on `prev == null`.)
*
* Releases must be paired with acquires. This class cannot tell an unpaired
* release from a real one, so callers guarantee the pairing; see `LocationFlow`,
* which throws rather than reaching `awaitClose` when nothing registered.
*/
class RefCountedSession(
private val setSessionActive: (Boolean) -> Unit,
) {
private val lock = Any()
private var holders = 0
fun setActive(active: Boolean) {
synchronized(lock) {
val wasActive = holders > 0
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
val isActive = holders > 0
if (isActive != wasActive) setSessionActive(isActive)
}
}
}
@@ -166,7 +166,7 @@ object BlossomPaymentHandler {
val preimageResult = CompletableDeferred<String?>()
try {
account.sendZapPaymentRequestFor(invoice, null) { response ->
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
// CompletableDeferred.complete is idempotent, so extra callbacks are harmless.
preimageResult.complete((response as? PayInvoiceSuccessResponse)?.result?.preimage)
}

Some files were not shown because too many files have changed in this diff Show More