Merge remote-tracking branch 'origin/main' into claude/shortpost-lock-list-selection-3pg1s2

This commit is contained in:
Claude
2026-08-12 02:05:07 +00:00
1023 changed files with 54026 additions and 9736 deletions
+27
View File
@@ -85,3 +85,30 @@ skills verified clean):
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
Event Store section corrected from "Android only" to commonMain/all platforms
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
## Phase 4 (2026-08): Store-implementer skills (external consumer request)
Three skills added at the request of an external Quartz consumer
(vespa-eventstore — a server-side `IEventStore` on Vespa that asserts result
parity against the SQLite store in CI). All three document the
**store/relay-implementer's perspective**, which `quartz-integration` and
`nostr-expert` (client-side) did not cover. Requirements doc: the skill-requests
file reviewed 2026-08-04; the requester's items #4 (storage-lifecycle-nips) was
folded into `event-store-semantics` per their own recommendation, and #5
(relay-server/geode policies) was declined as not currently needed.
- **`event-store-semantics/`** — the `IEventStore`/SQLite-store behavioral
contract as named rules (STORE-Fxx/Wxx/Dxx/Cxx/Sxx/Nxx) with a semantics
changelog for pin-bump review. Written from `QueryBuilder`,
`MergeQueryExecutor`, the seven `*Module.kt` files, and `IEventStore` KDoc.
- **`nip85-trusted-assertions/`** — the NIP-85 model (10040/30382/30383/30384/
30385), full tag vocabulary with value semantics, authorization conventions,
worked JSON examples, stability notes.
- **`searchable-events/`** — the `SearchableEvent` contract + maintenance
mandate, with `references/searchable-kinds.md` holding the exhaustive
kind → class → `indexableContent()` table (126 classes / 129 kinds) that
external search engines diff at version bumps.
Follow-ups suggested but not implemented: a shared JSON test-vector corpus for
filter semantics (testFixtures both the SQLite tests and external parity suites
could run), and a snapshot test pinning the searchable-kind set.
@@ -0,0 +1,325 @@
---
name: event-store-semantics
description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely.
---
# Event Store Semantics — the `IEventStore` / SQLite-store contract
The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`)
is the de-facto **reference implementation** of what a Quartz event store must do. Other
implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by
`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed
store) — reimplement its *observable behavior* and assert parity in CI. This skill states that
behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an
archaeology session through `QueryBuilder`/`MergeQueryExecutor`.
Every rule below was verified against the code as of this skill's last update. When you change
store behavior, **update the rule here in the same PR** and add a line to the
[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and
review pin bumps against this file.
## Key files
| Concern | File |
|---|---|
| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` |
| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` |
| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` |
| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` |
| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` |
| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` |
| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` |
| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) |
| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) |
| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) |
Executable spec: the test suites in
`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`,
`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`,
`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`,
`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test,
the test wins — and this file has a bug to fix.
## Kind classes (used throughout)
- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`.
- **Ephemeral**: `20000 ≤ kind < 30000`.
- **Addressable**: `30000 ≤ kind < 40000`.
- Everything else is a regular event.
---
## Filter matching (STORE-F)
**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to
`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses
`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with
`created_at == since == until` matches.
**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the
full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store.
(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they
simply never match.)
**STORE-F03 — tag filter combination.** Within one tag name, values are **OR**
(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND**
(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands
**every listed value** be present on the event — one join + equality per value — and composes by
AND with any plain `tags` in the same filter.
**STORE-F04 — only single-letter tag names are indexed (by default).**
`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`.
A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store.
Deployments can widen `shouldIndex`, but the stock contract is single-letter-only.
**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the
`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences:
`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the
query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via
plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other
tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and
when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored.
(An event has one d-tag, so AND-across-values could never match anyway.)
**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a
64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`,
`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification**
of hash matches, so a hash collision would return a false positive. Probability is negligible in
practice but nonzero — a parity harness comparing against an exact-match engine should know this
is the one place the reference can (theoretically) over-match.
**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter
becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with
SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits
10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45
counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`).
**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC`
tiebreak on equal `created_at` is applied **only when
`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default
**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite
returns them in storage order). Any newest-N is valid; a parity suite must not assert
same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the
`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors
run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte
from the single-SQL ordering.
**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape
"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through
`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind)
index cursors with dedup by id on the tag shape. This is an optimization, not a semantics
change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the
single-SQL plan (ordering caveat per STORE-F08).
**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything**
(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric:
it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented
on `QueryBuilder.delete`).
**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent
handling; don't rely on either outcome.** On the single-filter simple path an empty list
renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as
`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of
*only* empty-list filters degrades to the match-everything query. Known quirk; treat
empty-list filters as invalid input rather than replicating this shape.
**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows.
`limit = null` means unbounded. Negative limits are not defended against (don't send them).
**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.**
`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries;
it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work
targets the SQL semantics above, not `FilterMatcher`.
---
## Write path (STORE-W)
Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration
row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the
messages quoted below (they surface as the NIP-01 `OK false` reason).
**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
**STORE-W02 — addressable supersession.** Same as W01 with unique index
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't
parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique
indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""`
(empty string), which *does* dedupe normally.
**STORE-W03 — ephemeral events are never stored but are acked as accepted.**
`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000`
without writing (the live relay stream still broadcasts them). A DB-level backstop trigger
(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check.
**STORE-W04 — expired events are rejected at insert.** App-level check
(`event.isExpired()`) plus a trigger on the expiration-row insert
(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert`
**throws**; `batchInsert` returns `Rejected`.
**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a
future `expiration` store a row in `event_expirations`. Nothing filters them out of queries
after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep
returns the expired event.** Operators run the sweep periodically (README recommends ~15 min).
Re-inserting an already-expired event after the sweep is rejected per W04.
**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes
`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if
absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and
blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps
addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps
are also excluded from `authorsMissingOutbox()`.)
**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated
(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`,
`event_expirations`, `event_vanish`, and the FTS row follow the header by
`ON DELETE CASCADE` / trigger.
**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls
back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every
entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned
in input order; OK frames pair by event id, not order.
---
## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D)
**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose
owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06).
The id path has **no timestamp condition**: it deletes the target regardless of the relative
`created_at` values.
**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that
`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at` — **inclusive**; a
version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's
author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same
`created_at <=` treatment against `(kind, pubkey)`.
**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's
events is inserted like any regular event (it may be useful to other relays/clients) but its
delete pass removes zero rows and creates no blocking.
**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects
(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is
named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`.
Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target)
still deletes on arrival, but would not block a later re-insert.
**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.**
Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows
from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a
considered decision.** NIP-09 leaves it open; at least one external implementation
(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is
the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and
the changelog — parity suites key off it.
**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when
`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`.
(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope
vanish events are stored as regular events with no side effects.
**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose
**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the
vanish event itself survives), and blocks inserts of owned events with
`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is
inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey
(unique on `pubkey_hash`).
**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking
created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10
empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62
events do that.
---
## NIP-45 count (STORE-C)
**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single
filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so
`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed
(dedup) **before** counting — an event matching several filters counts once. FTS-off + search
term → 0 (F-series search rules apply).
---
## NIP-50 search inside the store (STORE-S)
The indexing surface (which kinds are searchable, what text they contribute) is the
`searchable-events` skill; these rules are the store's query-side contract.
**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs
`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are
removed before FTS. Unsupported extensions are **ignored, never matched as literal text and
never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores
that *do* implement extensions receive the raw string through the relay layer and parse it with
`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc).
**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match
first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not
the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a
search term (best/min rank per event across branches); mixing search and non-search filters
falls back to `created_at DESC`.
**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/
since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions.
**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to
FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics.
Tokenization details live in `FullTextSearchModule` (see `searchable-events`).
**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a
non-empty search term matches **nothing** (query/count/delete alike); an empty-string search
imposes no constraint. Everything else is unchanged.
**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does):
tokenization moves off the insert path to a watermark-driven catch-up
(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50
results are exactly as fresh as the synchronous path.
---
## Negentropy / NIP-77 (STORE-N)
**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the
**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order
unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow
sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when
`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact
across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the
non-itemizable cases).
---
## Configuration presets
- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off,
`useAndIndexIdOnOrderBy` off, no live negentropy index.
- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and
tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves
`useAndIndexIdOnOrderBy` off.
- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the
index on next open (`ensureOptionalIndexes`), no migration.
## For parity implementers
- Treat the rule ids above as the vocabulary for divergence notes
(e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion").
- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for
holding a second engine to it.
- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when
diffing results byte-for-byte — both are places where a "divergence" may be the reference's
own slack, not your bug.
## Semantics changelog
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
- 2026-08-04 (baseline) — rules F01–F13, W01–W08, D01–D08, C01, S01–S06, N01 written from the
code at the time this skill was introduced. Changes before this date are not itemized;
archaeology starts at `git log` on `nip01Core/store/`.
@@ -0,0 +1,232 @@
---
name: nip85-trusted-assertions
description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store.
---
# NIP-85 Trusted Assertions — the Quartz model
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`.
NIP-85 is still an evolving spec; **this package is the operative definition** of what
Amethyst-family software writes and reads. This skill states the model (who signs what about
whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume.
## The model in one paragraph
An **assertion is signed by the asserting party** (a trust provider service, or the user
themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so
"latest card by provider P about subject S" is just the addressable coordinate
`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's
**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch
their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal
event signatures; the 10040→assertion link is **consumer-side convention** (see
"Authorization" below).
## Kind map
| Kind | Class | Kind class | d-tag = the subject | Content |
|---|---|---|---|---|
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)`
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
tags plus topics; the encrypted card content is intentionally never indexed.
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
There is **no fixed tag name**: `tag[0]` *is* the service string.
```json
["30382:rank", "<provider pubkey, 64 hex>", "wss://nip85.brainstorm.world"]
```
- `ServiceType(kind, type)` parses/renders `"<kind>:<type>"` — kind must be an int, the first
`:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the
allocation-free prefix check.
- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey
(length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) —
entries failing any check are silently dropped, which is what keeps foreign tags like
`["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`).
- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take
`isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value
equality.
- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types —
`30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/
`30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf →
brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types
Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`,
`30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on.
## Authorization — what a consumer may assume
- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the
observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming
code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`:
`rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the
provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are
simply ignored for trust purposes (they may still be stored; dropping them — as an external
store's orphan sweep does — is a legitimate storage policy, not a protocol rule).
- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
metric (`liveUserRankProvider`, `liveUserFollowerCount` in
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`).
- **Multi-provider combination is unprescribed.** When two listed providers assert different
ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per
metric slot. Consumers choose their own policy — document it.
- The relay URL in the entry is a **fetch hint, and it is honored**:
`amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay
(`kinds=[30382], authors=[provider], #d=[targets]`).
### The dual use of kind 30382
The same kind serves two roles, distinguished **by author**:
1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`,
`followers`, `hops`, …); this is what 10040 discovery points at.
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
provider cards.
## Tag vocabulary and value semantics
All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric
value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name, value.toString()]`.
**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range
validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) —
consumers must defend.
**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
| Tag name | Accessor | Type | Semantics |
|---|---|---|---|
| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0–100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. |
| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). |
| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. |
| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. |
| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. |
| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. |
| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. |
| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. |
| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. |
| `t` (repeatable) | `topics()` | List\<String> | Subject's topics/interests. |
| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. |
| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). |
**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`,
`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats).
**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`.
## Building and parsing (use the typed helpers, not raw `arrayOf`)
```kotlin
// Provider list: declare a rank provider (this is what `amy graperank register` does)
val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl)
val list = TrustProviderListEvent.create(tag, isPrivate = false, signer)
// or append to an existing one:
val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer)
val providers: List<ServiceProviderTag> = updated.serviceProviders() // public
val private = updated.privateTags(signer)?.serviceProviders() // private side
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
val card = ContactCardEvent.create(
targetUser = subjectPubkey,
signer = providerSigner,
publicInitializer = {
rank(87)
followers(1234)
hops(2)
},
)
card.aboutUser() // d-tag → subject pubkey
card.rank() // 87
// Event assertion: unsigned template only (30383/84/85 have build(), no create())
val template = EventAssertionEvent.build(targetEventId) {
rank(12)
reactionCount(40)
zapAmount(2100)
}
val signed = signer.sign(template)
```
## Worked end-to-end example
Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`):
```json
{ "kind": 10040, "pubkey": "<O>",
"tags": [
["30382:rank", "<P>", "wss://nip85.brainstorm.world"],
["30382:followers", "<P>", "wss://nip85.brainstorm.world"]
],
"content": "" }
```
Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:<P>:<S>`):
```json
{ "kind": 30382, "pubkey": "<P>",
"tags": [
["d", "<S>"],
["rank", "87"], ["followers", "1234"], ["hops", "2"]
],
"content": "" }
```
`P` asserts about an event `E` (kind 30383, addressable at `30383:<P>:<E>`):
```json
{ "kind": 30383, "pubkey": "<P>",
"tags": [["d", "<E>"], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]],
"content": "" }
```
Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe
`{kinds:[30382], authors:["<P>"], "#d":["<S>", …]}` at the hinted relay → newest card per
address wins → `rank()`.
Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real
10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four
assertion kinds with every tag populated).
## Freshness / supersession
Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag
convention and **no prescribed refresh cadence** — staleness policy is the consumer's.
Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when
`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the
card's `a`-tag (`30382:<provider>:<target>`).
## Stability notes (as of 2026-08)
- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape;
`rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint
consumption.
- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/
zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders +
tests exist; no in-repo publisher yet).
- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array<Any>` —
dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params
are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/`
(stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d`
tag is reflected by `dTag()` even though the convention is `""`.
## Where it's consumed (reading list)
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
+26
View File
@@ -94,6 +94,32 @@ class MetadataFilterAssembler(
Assemblers stay pure — no state, no I/O. They're the composition seam: `FeedMetadataCoordinator` takes a list of visible notes and assembles a single metadata filter covering every referenced pubkey.
## Per-visible loading — the canonical entry points (`observeUser*` / `observeNote*`)
Prefer these over hand-rolled "load metadata for this list" calls. They are the shared,
KMP way to load data **only for what's on screen** — a composable subscribes while it is in
composition and unsubscribes ~30s after it leaves (or the app backgrounds). Both live in
`commons/relayClient/`:
- **Per user** (`relayClient/user/`): `observeUserInfo/Picture/Banner/AboutMe/Name(user)`
each open a composition-scoped `UserFinderFilterAssemblerSubscription(user)` **and** return
reactive `State`. Metadata (kind 0 + relay lists) loads for on-screen users only, coalesced
into one batched REQ per relay for the whole visible set.
- **Per note** (`relayClient/event/`): `EventFinderFilterAssemblerSubscription(note)` loads a
note's interactions (reactions / zaps / reposts / replies) while it is composed. Android's
`observeNote*` display observers layer on top of the same subscription.
Both read front-end-provided CompositionLocals — `LocalUserFinder` / `LocalUserFinderAccount`
(reused by the event finder) / `LocalEventFinder` — provided once near the composition root
(Android `AppModules`, Desktop `Main.kt` via its subscriptions coordinator). The account seam
is the narrow `UserFinderAccount` (snapshot relay-hint getters), NOT the fat `IAccount`.
`error()` defaults mean these must never be reached from a composition without a relay client
(e.g. the Android `:napplet` sandbox).
The load-once, viewport-batch path (`FeedMetadataCoordinator.loadMetadataForNotes` /
`loadMetadataBatched`) is superseded for foreground loading; `MetadataPreloader` remains only
as an optional off-screen background warmer.
## Preloaders
`MetadataPreloader` is the "I need metadata for 200 pubkeys, but don't melt my CPU or the relay" path. It uses `MetadataRateLimiter` (token bucket) to throttle bulk fetches and group them into relay-friendly chunks.
+117
View File
@@ -0,0 +1,117 @@
---
name: searchable-events
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
---
# Searchable Events — the NIP-50 indexing surface
## The contract
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
```kotlin
interface SearchableEvent {
fun indexableContent(): String
}
```
One method; marker and extractor in one. An event kind is searchable **iff** its event class
implements this interface **and** the class is wired into `EventFactory` (the stores probe
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
Rules every implementation follows (keep them when adding one):
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
first and join the extracted fields**, never the raw JSON.
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
the public petname/summary/topics, never the NIP-44 payload).
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
## The full kind table
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
implementor with its kind number, class, and the exact `indexableContent()` expression
(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to
answer "did the searchable set or any kind's indexed text change?".
Notables that surprise people:
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
website, picture URL, …).
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so
`title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()`
is dead on the store path.
- Poll kinds (1068, 6969) append each option label on its own line.
## MANDATORY maintenance when you touch this surface
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
change ships them stale search results).
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
is the contract. App-side, schedule the resumable overload after shipping such a change.
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
minus their comment, relay lists) stay out to keep the index small.
## How the stores consume it
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` —
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
`event-store-semantics` skill.
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
the same tokenizer runs on queries so drift cancels.
## NIP-50 client side
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
query, never match-nothing. A server-side store that implements its own extensions
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
`commons/.../account/AccountBootstrapEvents.kt`.
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
## Tests (executable spec)
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
FTS cleanup on replaceable rotation.
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
limit-after-score, multi-filter rank union.
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.
@@ -0,0 +1,166 @@
# Searchable kinds — the authoritative implementor table
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
expression. **Update this file in the same PR as any change to the searchable set or to an
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
kind 30063 has a collision — see the footnote). File paths are under
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| Kind | Class | Package | `indexableContent()` |
|---|---|---|---|
| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) |
| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL |
| 9 | ChatEvent | nipC7Chats | `content` |
| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL |
| 14 | ChatMessageEvent | nip17Dm/messages | `content` |
| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL |
| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
| 24 | PublicMessageEvent | nipA4PublicMessages | `content` |
| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) |
| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) |
| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` |
| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL |
| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) |
| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL |
| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` |
| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL |
| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL |
| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL |
| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` |
| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL |
| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` |
| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL |
| 1617 | GitPatchEvent | nip34Git/patch | `content` |
| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL |
| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL |
| 1622 | GitReplyEvent | nip34Git/reply | `content` |
| 1630–1633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) |
| 1808 | AudioHeaderEvent | experimental/audio/header | `content` |
| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL |
| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL |
| 2004 | TorrentCommentEvent | nip35Torrents | `content` |
| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL |
| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` |
| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` |
| 9734 | LnZapRequestEvent | nip57Zaps | `content` |
| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL |
| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL |
| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL |
| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL |
| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL |
| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL |
| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL |
| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL |
| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL |
| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL |
| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` |
| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` |
| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` |
| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` |
| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL |
| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL |
| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL |
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL |
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL |
| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL |
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
| 30315 | StatusEvent | nip38UserStatus | `content` |
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL |
| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL |
| 31871 | AttestationEvent | experimental/attestations/attestation | `content` |
| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` |
| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL |
| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` |
| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL |
| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL |
| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` |
| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) |
| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL |
| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL |
| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL |
| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL |
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
| 45001 | ForumPostEvent | buzz/forum | `content` |
| 45003 | ForumCommentEvent | buzz/forum | `content` |
| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` |
† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also
declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps
30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes
`title()\ndescription()`. If the factory mapping ever changes, this table changes with it.
## Abstract bases (no kind of their own)
| Base class | Body | Concrete kinds |
|---|---|---|
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
## How to regenerate / verify this table
```bash
# All implementor files:
grep -rln "override fun indexableContent" quartz/src/commonMain
# For each, pair the KIND constant with the indexableContent() body.
# Searchability on the store path additionally requires EventFactory registration:
grep -n "<ClassName>" quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
```
A CI-diffable snapshot test (assert the set of kinds whose `EventFactory` product implements
`SearchableEvent` against a checked-in list) would make this table impossible to go stale —
suggested follow-up, not yet implemented.
+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
+118 -31
View File
@@ -26,8 +26,12 @@ env:
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
# embeds the AppDir as-is, which is what we actually want.
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
#
# Both arch binaries come from the same appimagetool release so their SHA256
# values move in lockstep on version bumps.
APPIMAGETOOL_VERSION: '1.9.0'
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
jobs:
# ---------------------------------------------------------------------------
@@ -38,11 +42,32 @@ jobs:
strategy:
fail-fast: false
matrix:
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
# ubuntu-24.04-arm label is a standard free public-repo runner as of
# early 2025). Windows arm64 uses windows-11-arm, added to the free
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
# jpackage / jlink / Compose Multiplatform 1.11 all produce
# host-native artifacts — no cross-compilation needed.
#
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
# windows-11-arm runner image ships no WiX (the windows-latest image
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
# MSI). Installing it here would mean pulling an archived, x86-only
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
# the candle/light CLI that JDK 21's jpackage requires) into the job
# that publishes signed release assets. The portable zip is the
# documented Windows install path for amy/geode already, so arm64
# Windows users get that until either the runner image gains WiX or
# jpackage learns the WiX 4+ CLI.
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
runs-on: ${{ matrix.os }}
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
defaults:
@@ -53,7 +78,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
@@ -93,13 +118,21 @@ jobs:
set -euo pipefail
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
sudo apt-get update && sudo apt-get install -y desktop-file-utils
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
case "${{ matrix.arch }}" in
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
esac
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
curl -fsSL --retry 3 "$URL" -o "$DEST"
actual=$(sha256sum "$DEST" | awk '{print $1}')
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
exit 1
fi
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
chmod +x "$DEST"
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
# (runtime-version is greped from the manifest so this never drifts).
@@ -203,17 +236,32 @@ jobs:
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
# against the bundled JRE under lib/runtime/, NOT the app payload under
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
# install without EGL crashes at startup with:
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
- name: Add libegl1 dep to arm64 .deb
if: matrix.family == 'linux' && matrix.arch == 'arm64'
run: |
set -euo pipefail
chmod +x scripts/add-deb-libegl-dep.sh
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Build portable archives (windows + linux-portable)
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
run: |
set -euo pipefail
VER="${{ steps.ver.outputs.version }}"
ARCH="${{ matrix.arch }}"
APP="desktopApp/build/compose/binaries/main-release/app"
mkdir -p desktopApp/build/portable
if [[ "${{ matrix.family }}" == "windows" ]]; then
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
else
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
fi
# Flatpak bundle: wraps the same createReleaseDistributable tree the
@@ -230,6 +278,17 @@ jobs:
PKG="desktopApp/packaging/flatpak"
APP_ID="com.vitorpamplona.amethyst.Desktop"
OUT="desktopApp/build/flatpak"
# AppImage-style arch names for the bundle filename.
case "${{ matrix.arch }}" in
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
esac
# Rewrite the arch-specific GStreamer plugin path in the manifest
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
# matches the original triplet.
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
"${PKG}/${APP_ID}.yml"
# Inject the AppStream <release> entry for this build (the checked-in
# metainfo deliberately carries none — CI is the source of truth).
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
@@ -241,7 +300,7 @@ jobs:
"${OUT}/build-dir" \
"${PKG}/${APP_ID}.yml"
flatpak build-bundle "${OUT}/repo" \
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
"$APP_ID" \
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
ls -la "$OUT"
@@ -285,7 +344,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 }}
@@ -325,8 +384,17 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
# and jpackageMsi for a CLI is deferred (the portable zip is the
# documented Windows install path). The launcher script writes both
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
# zips the image on Windows instead of tar.gz.
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -337,7 +405,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 +604,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 }}
@@ -574,8 +642,16 @@ jobs:
fail-fast: false
matrix:
include:
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
# deferred (portable zip covers the primary use — operators still
# deploy geode via the Docker image or the tarball on Linux). The
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
# smoke test below runs under bash on the windows runner.
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
runs-on: ${{ matrix.os }}
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
defaults:
@@ -586,7 +662,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
@@ -630,12 +706,23 @@ jobs:
# module list is complete for the real relay path (Ktor CIO + SQLite +
# NIP-11 serialization) — a too-tight module list links fine but fails
# here with NoClassDefFound instead of on an operator's machine.
#
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
# tmp path also differs between git-bash on Windows (which resolves /tmp
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
# because the workflow's `defaults.run.shell: bash` uses git-bash on
# Windows and /tmp is a valid mingw path there.
- name: Smoke-test the geode image
run: |
set -euo pipefail
IMG="geode/build/geode-image/geode"
"$IMG/bin/geode" --version
"$IMG/bin/geode" --port 17447 &
if [[ "${{ matrix.family }}" == "windows" ]]; then
LAUNCHER="$IMG/bin/geode.bat"
else
LAUNCHER="$IMG/bin/geode"
fi
"$LAUNCHER" --version
"$LAUNCHER" --port 17447 &
PID=$!
ok=0
for i in $(seq 1 20); do
@@ -777,7 +864,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 +918,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 +953,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 +1097,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 }}
+31 -6
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
@@ -49,16 +49,24 @@ jobs:
# package, installs it, and verifies the process stays alive for 10s.
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
# (java.management, java.prefs), and native lib bundling issues.
#
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
# at PR time instead of on the tag build.
# -------------------------------------------------------------------------
release-deb-launch:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, ubuntu-24.04-arm]
runs-on: ${{ matrix.os }}
timeout-minutes: 45
steps:
- name: Checkout code
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
@@ -84,13 +92,29 @@ jobs:
chmod +x scripts/relax-deb-libicu.sh
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
# Mirrors the same step in create-release.yml so this job exercises the
# exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in
# DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without
# this the arm64 app dies at startup with
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
# See scripts/add-deb-libegl-dep.sh for the full rationale.
- name: Add libegl1 dep to arm64 .deb
run: |
set -euo pipefail
chmod +x scripts/add-deb-libegl-dep.sh
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
- name: Install .deb
run: |
# Installed via apt (not `dpkg -i`) so the .deb's declared Depends are
# actually resolved — that is what pulls in libegl1 on the arm64
# runner, which does not ship it preinstalled.
#
# jpackage's post-install script runs xdg-desktop-menu which fails
# on CI runners ("No writable system menu directory"). The files are
# extracted successfully; only the menu registration fails. Allow the
# dpkg error, then verify the binary was actually installed.
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
# install error, then verify the binary was actually installed.
sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true
echo "Installed files:"
dpkg -L amethyst | head -30
# Fail if the binary wasn't actually extracted
@@ -139,5 +163,6 @@ jobs:
if: always()
uses: actions/upload-artifact@v7
with:
name: Release DEB (smoke-tested)
# Artifact names must be unique across a run — disambiguate per arch.
name: Release DEB (smoke-tested, ${{ matrix.os }})
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
+33 -12
View File
@@ -35,7 +35,12 @@ All platforms:
Platform-specific:
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
themselves are x86 and run under emulation).
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
`flatpak-builder` for the Flatpak bundle (see
@@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
sudo apt-get install -y desktop-file-utils
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
ARCH="$(uname -m)"
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
```
---
@@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources.
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
| Windows `.zip` portable | See below (inline `7z`) | — |
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
@@ -325,14 +333,27 @@ Quartz library in one pipeline.
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
4. **Verify** — the GH Release should hold **31 assets**:
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
configured, so macOS ships arm64-only.
4. **Verify** — the GH Release should hold **47 assets**:
- **14 desktop**, one per matrix leg × format:
- macOS arm64: `dmg` (1)
- Windows x64: `msi` + portable `zip` (2)
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
- Linux x64 / arm64: `deb` + `rpm` (4)
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
and no Intel runner leg is configured, so macOS ships arm64-only.
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
jpackage learns the WiX 4+ `wix build` CLI.
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
F-Droid `.apks` set built for Accrescent.
- **5 amy** + **5 geode** bundles.
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
- **10 geode** — same shape as amy.
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
- Android flow unchanged
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 26–28 (Android 8–9):** `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 26–28 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 8–11** — 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 26–30 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 8–11.
- 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 8–11" 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 8–11**,
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 8–11 the throw would land on the first
iteration, before any registration exists:
- "location is dead on Android 8–11" — **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 2–6 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 20648–20663 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 20659–20665 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 1–6 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:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), 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",
)
}
@@ -18,11 +18,15 @@
* 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.relay
package com.vitorpamplona.amethyst.ui.components
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
class RelayTopNavPerRelayFilterSet(
val relayUrl: NormalizedRelayUrl,
) : IFeedTopNavPerRelayFilterSet
/**
* No translation service in this flavor, so no note is ever translated and the copy-text
* menus never need to offer a "Copy Translated" option.
*/
fun cachedTranslation(
content: String,
accountViewModel: AccountViewModel,
): String? = null
@@ -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.
@@ -709,6 +723,7 @@ class AppModules(
torManager.status,
client,
applicationIOScope,
onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) },
)
// Verifies and inserts in the cache from all relays, all subscriptions
@@ -871,7 +886,6 @@ class AppModules(
AccountCacheState(
geolocationFlow = { locationManager.geohashStateFlow },
nwcFilterAssembler = { sources.nwc },
cashuWalletFilterAssembler = { sources.cashuWallet },
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
contentResolverFn = { appContext.contentResolver },
@@ -957,6 +971,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 +986,8 @@ class AppModules(
scope = applicationIOScope,
accountsCache = accountsCache,
localPreferences = LocalPreferences,
subscriptions = accountSubscriptions,
isForeground = foregroundTracker.isForeground,
activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey },
)
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
return when (event) {
is RootNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
"",
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: "Napplet",
event.requires(),
HostProfile.NAPPLET,
context = context,
manifest = event,
authorPubKey = event.pubKey,
identifier = "",
)
is NamedNappletEvent ->
NappletLauncher.buildLaunchParams(
context,
event.paths(),
event.servers(),
event.pubKey,
event.identifier(),
event.declaredAggregateHash() ?: event.computeAggregateHash(),
event.title() ?: event.identifier(),
event.requires(),
HostProfile.NAPPLET,
context = context,
manifest = event,
authorPubKey = event.pubKey,
identifier = event.identifier(),
)
is RootSiteEvent ->
NappletLauncher.buildLaunchParams(
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))),
idleTimeoutMs = 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() }
}
}
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
@@ -501,6 +503,18 @@ class AccountSettings(
return false
}
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
// Settings as hidden, which would leave no route back to the screen that hides rows.
val sanitized = DrawerItemVisibility.sanitize(newItems)
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
saveAccountSettings()
return true
}
return false
}
/** The selected default spend rail across both NWC wallets and CLINK debits. */
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
val navigation =
AccountNavigationPreferences(
MutableStateFlow(internalSettings.navigation.bottomBarItems),
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
)
fun toInternal(): AccountSyncedSettingsInternal =
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
.map { it.id }
.sorted(),
),
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
navigation =
AccountNavigationPreferencesInternal(
navigation.bottomBarItems.value,
navigation.hiddenDrawerItems.value.toNames(),
),
)
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
if (navigation.bottomBarItems.value != newBottomBarItems) {
navigation.bottomBarItems.tryEmit(newBottomBarItems)
}
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
}
}
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
@Stable
class AccountNavigationPreferences(
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
)
@Stable
@@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal(
// favorite apps, and individual joined chats/groups). Defaulted so blobs
// written before this field existed decode to the app's current defaults.
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
// Empty by default, which is what makes a newly shipped destination visible
// to everyone without a migration — see DrawerItemVisibility.
//
// Stored as strings rather than the enum on purpose: an id written by a
// newer client would fail the enum decoder and take the whole synced-settings
// blob down with it, so unknown names are dropped on read instead (the same
// approach AccountPoWPreferencesInternal.enabledCategories takes).
var hiddenDrawerItems: List<String> = emptyList(),
)
@Serializable
@@ -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)
}
}
}
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
*/
data object Expired : ConcordInviteResult
/**
* The link opens, but this community's roster has banned us (CORD-04).
*
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
* URL and its unlock token forever — so honouring the link alone would hand the new keys to the
* very account the rotation expelled.
*/
data object Banned : ConcordInviteResult
/**
* The bundle event was found but could not be opened with the link's token —
* typically because it was minted by a newer/incompatible Concord client whose
@@ -0,0 +1,37 @@
/*
* 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.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* 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(pubkey: 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,
@@ -62,6 +62,28 @@ class IndexerRelayListState(
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
/**
* Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
*
* At login `indexerListNote.event` is usually still null and this resolves through
* `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public
* indexer relays gets its own relays immediately instead of the defaults.
*/
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
/**
* The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup]
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
* what the user actually configured.
*
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
* `emptySet()` seed left a window where `.value` contradicted the contract above.
*/
val flow =
getIndexerRelayListFlow()
.map { normalizeIndexerRelayListWithBackup(it.note) }
@@ -70,7 +92,7 @@ class IndexerRelayListState(
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
normalizeIndexerRelayListPrecached(indexerListNote),
)
val flowNoDefaults =
@@ -62,6 +62,31 @@ class SearchRelayListState(
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
/**
* Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
*
* At login `searchListNote.event` is usually still null and this resolves through
* `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public
* search relays gets its own relays immediately instead of the defaults. Accounts whose relays
* are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands.
*/
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
/**
* The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup]
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
* user actually configured.
*
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
* left a window where `.value` contradicted the "never empty" contract above and search
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
* private entries, since the first emission waits on a remote decrypt.
*/
val flow =
getSearchRelayListFlow()
.map { normalizeSearchRelayListWithBackup(it.note) }
@@ -70,7 +95,7 @@ class SearchRelayListState(
.stateIn(
scope,
SharingStarted.Eagerly,
emptySet(),
normalizeSearchRelayListPrecached(searchListNote),
)
val flowNoDefaults =
@@ -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)
}
// ============================================================
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
normalizedUrl: String,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
// Overlay-mesh hosts (0200::/7) are reachable only through the local mesh
// interface — Tor cannot route the range, so proxying only breaks the fetch.
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
true
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
isOnionRelaysActive: Boolean,
final: Boolean,
): Boolean =
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
false
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
isOnionRelaysActive
@@ -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
@@ -57,7 +57,27 @@ class DataStoreNappletStorage(
key: String,
value: String,
) {
dataStore.edit { it[keyOf(coordinate, key)] = value }
dataStore.edit { preferences ->
val prefix = prefixOf(coordinate)
val target = keyOf(coordinate, key)
val currentBytes =
preferences
.asMap()
.entries
.asSequence()
.filter { it.key.name.startsWith(prefix) }
.sumOf { (storedKey, storedValue) ->
storedKey.name
.removePrefix(prefix)
.encodeToByteArray()
.size +
((storedValue as? String)?.encodeToByteArray()?.size ?: 0)
}
val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0)
val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size
require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." }
preferences[target] = value
}
}
override suspend fun remove(
@@ -87,4 +107,9 @@ class DataStoreNappletStorage(
coordinate: String,
key: String,
) = stringPreferencesKey(prefixOf(coordinate) + key)
companion object {
/** NAP-STORAGE's recommended per-napplet UTF-8 quota. */
const val MAX_STORAGE_BYTES = 512 * 1024
}
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
@@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
@@ -93,7 +95,11 @@ class NappletBrokerService : Service() {
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
private val liveSubscriptions = NappletLiveSubscriptions()
private val liveSubscriptions = NappletLiveSubscriptions(scope)
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
private val resourceRequests = ConcurrentHashMap<String, Job>()
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
@@ -117,7 +123,7 @@ class NappletBrokerService : Service() {
override fun onDestroy() {
liveSubscriptions.closeAll()
identityWatch.stop()
identityWatch.stopAll()
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
@@ -280,38 +286,57 @@ class NappletBrokerService : Service() {
// Resolve the launch token to the trusted identity + declared set. The sandbox never states
// its own coordinate, so a compromised :napplet process can only ever act as the napplet it
// was launched as (it holds only its own token). An unknown token = no session; refuse.
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN))
val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
val session = NappletLaunchRegistry.resolve(launchToken)
if (session == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
return true
}
val identity = session.identity
val declared = session.declared
val resourceRequestKey = "$launchToken\u0000$requestId"
if (requestType == "resource.cancel") {
resourceRequests.remove(resourceRequestKey)?.cancel()
return true
}
val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany"
scope.launch {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
// decision (it stays wire-identical with the future desktop host). This service only supplies
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
// The launch token decides whose key signs — not the active account. A surface opened by
// one account can never be handed another's signer, even while it stays open across a switch.
val broker = brokerFor(session.accountPubKey)
if (broker == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
is NappletRequestRouter.Outcome.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
val requestJob =
scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) {
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
// decision (it stays wire-identical with the future desktop host). This service only supplies
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
// The launch token decides whose key signs — not the active account. A surface opened by
// one account can never be handed another's signer, even while it stays open across a switch.
val broker = brokerFor(session.accountPubKey)
if (broker == null) {
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
return@launch
}
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
is NappletRequestRouter.Outcome.Ignore -> {}
is NappletRequestRouter.Outcome.Reply -> {
reply(replyTo, requestId, outcome.payload)
// NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup
// snapshot succeeds, the runtime owns identity.changed delivery for this
// trusted launch token until the broker service closes.
if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) {
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
}
}
is NappletRequestRouter.Outcome.OpenSubscription ->
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
}
}
if (tracksResourceRequest) {
resourceRequests.put(resourceRequestKey, requestJob)?.cancel()
requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) }
requestJob.start()
}
return true
}
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
@StringRes
fun NappletCapability.labelRes(): Int =
when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys
NappletCapability.RELAY -> R.string.napplet_cap_relay
@@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int =
@StringRes
fun NappletCapability.descriptionRes(): Int =
when (this) {
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
@@ -294,9 +294,10 @@ class NappletConsentSummary(
context.getString(R.string.napplet_consent_pay_no_amount)
}
}
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
}
}
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
/**
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
@@ -34,31 +35,35 @@ import kotlinx.coroutines.launch
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
* (or `""` when no account is signed in) to the caller-supplied sink.
*
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the
* router confirmed the applet declared the IDENTITY capability.
* Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's
* streams. A watch starts only after that surface successfully obtains its public-key snapshot.
*/
class NappletIdentityWatch(
private val scope: CoroutineScope,
private val pubKey: (boundPubKey: String) -> Flow<String>,
) {
private var job: Job? = null
private val jobs = ConcurrentHashMap<String, Job>()
fun start(
watchId: String,
boundPubKey: String,
push: (String) -> Unit,
) {
stop()
job =
scope.launch {
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
}
jobs.computeIfAbsent(watchId) { id ->
scope
.launch {
pubKey(boundPubKey)
.distinctUntilChanged()
.drop(1)
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
}.also { job ->
job.invokeOnCompletion { jobs.remove(id, job) }
}
}
}
fun stop() {
job?.cancel()
job = null
fun stopAll() {
jobs.values.forEach { it.cancel() }
jobs.clear()
}
}
@@ -76,7 +76,7 @@ object NappletLaunchRegistry {
accountPubKey: HexKey,
): String {
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
sessions[token] = Session(identity, declared, accountPubKey)
sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey)
return token
}
@@ -25,16 +25,20 @@ import android.content.Intent
import android.content.res.Configuration
import android.os.Bundle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
import com.vitorpamplona.quartz.utils.Log
/**
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
@@ -49,20 +53,18 @@ object NappletLauncher {
manifest: NappletManifest,
authorPubKey: HexKey,
identifier: String,
) = launch(
context = context,
paths = manifest.paths(),
servers = manifest.servers(),
authorPubKey = authorPubKey,
identifier = identifier,
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
requires = manifest.requires(),
)
) {
val event = manifest as? Event
if (event?.verify() != true || event.pubKey != authorPubKey) {
Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" }
return
}
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
}
/**
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite —
* the broker then refuses every capability, so the site renders as inert static content.
* Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified
* manifest overload so raw callers cannot bypass signature/author validation.
*/
fun launch(
context: Context,
@@ -73,12 +75,26 @@ object NappletLauncher {
aggregateHash: HexKey?,
title: String,
requires: List<String>,
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
profile: HostProfile = HostProfile.NAPPLET,
// Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must
// use the signature-checking manifest overload above.
profile: HostProfile,
) {
if (profile != HostProfile.WEBSITE) {
Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" }
return
}
val params =
runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) }
.onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) }
.getOrNull()
?: return
openHost(context, params)
}
private fun openHost(
context: Context,
params: Bundle,
) {
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
val intent =
Intent(context, NappletHostActivity::class.java).apply {
putExtras(params)
@@ -105,6 +121,29 @@ object NappletLauncher {
requires: List<String>,
profile: HostProfile,
): Bundle {
require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." }
return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
}
private fun buildLaunchParamsTrusted(
context: Context,
paths: List<PathTag>,
servers: List<String>,
authorPubKey: HexKey,
identifier: String,
aggregateHash: HexKey?,
title: String,
requires: List<String>,
profile: HostProfile,
): Bundle {
val effectiveAggregateHash =
if (profile == HostProfile.NAPPLET) {
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
}
} else {
aggregateHash
}
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
@@ -118,7 +157,7 @@ object NappletLauncher {
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash)
val declared = profile.declaredCapabilities(requires)
// Bound to the account launching it, so the surface keeps signing as that account even if the
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
@@ -156,7 +195,7 @@ object NappletLauncher {
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash)
putString(NappletHostContract.EXTRA_TITLE, title)
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
@@ -170,4 +209,34 @@ object NappletLauncher {
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
}
}
/** Signature-checking entry point for embedded NIP-5D surfaces. */
fun buildLaunchParams(
context: Context,
manifest: NappletManifest,
authorPubKey: HexKey,
identifier: String,
): Bundle? {
val event = manifest as? Event
if (event?.verify() != true || event.pubKey != authorPubKey) {
Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" }
return null
}
return runCatching {
buildLaunchParamsTrusted(
context = context,
paths = manifest.paths(),
servers = manifest.servers(),
authorPubKey = authorPubKey,
identifier = identifier,
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
requires = manifest.requires(),
profile = HostProfile.NAPPLET,
)
}.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) }
.getOrNull()
}
private const val TAG = "NappletLauncher"
}
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
@@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger
* signatures still came from the old one. [open] is reached only after the broker authorized the
* subscription (RELAY consent).
*/
class NappletLiveSubscriptions {
class NappletLiveSubscriptions(
private val scope: CoroutineScope,
) {
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
private val liveSeq = AtomicInteger(0)
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
val client: INostrClient,
) {
val eoseSent = AtomicBoolean(false)
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
var deliveryJob: Job? = null
}
private sealed interface Delivery {
data class RelayEvent(
val event: Event,
) : Delivery
data object Eose : Delivery
data class Closed(
val reason: String,
) : Delivery
}
/**
@@ -77,15 +97,31 @@ class NappletLiveSubscriptions {
// can't collide with the subscription it's replacing.
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
liveSubs[nappletSubId] = sub
sub.deliveryJob =
scope.launch {
for (delivery in sub.deliveries) {
if (liveSubs[nappletSubId] !== sub) break
when (delivery) {
is Delivery.RelayEvent ->
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
}
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
}
}
}
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
) {
sub.deliveries.trySend(Delivery.RelayEvent(event))
}
// A subscription fans out to several relays; collapse their EOSEs into the single
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
}
override fun onClosed(
message: String,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
) {
sub.deliveries.trySend(Delivery.Closed(message))
}
}
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
@@ -109,12 +147,18 @@ class NappletLiveSubscriptions {
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
fun close(nappletSubId: String) {
val sub = liveSubs.remove(nappletSubId) ?: return
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
/** Tears down every open subscription (service teardown). */
fun closeAll() {
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
liveSubs.values.forEach { sub ->
sub.deliveries.close()
sub.deliveryJob?.cancel()
runCatching { sub.client.unsubscribe(sub.clientSubId) }
}
liveSubs.clear()
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.napplet
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
internal object NappletRelayCleartext {
suspend fun forDelivery(
event: Event,
signer: NostrSigner,
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
internal suspend fun forDelivery(
event: Event,
userPubKey: HexKey,
decrypt: suspend (String, HexKey) -> String,
): Event? {
if (!isEncrypted(event)) return event
val peer =
when {
event.pubKey == userPubKey -> event.recipientPubKey()
event.isAddressedTo(userPubKey) -> event.pubKey
else -> null
} ?: return null
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
// signature fields so callers can still correlate it, while making clear that this object
// must never be republished as a signed event after its content projection has changed.
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
}
internal fun isEncrypted(event: Event): Boolean =
event is PrivateDmEvent ||
EncryptedInfo.isNIP04(event.content) ||
isNip44V2(event.content)
private fun isNip44V2(content: String): Boolean =
content.length >= MIN_NIP44_V2_LENGTH &&
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
private fun Event.recipientPubKey(): HexKey? =
tags.firstNotNullOfOrNull { tag ->
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
}
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
private const val MIN_NIP44_V2_LENGTH = 132
}
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
@@ -255,7 +256,7 @@ class AccountNappletGateways(
emptyList()
} else {
runCatching {
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
}.getOrDefault(emptyList())
}
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
@@ -265,7 +266,8 @@ class AccountNappletGateways(
.distinctBy { it.id }
.sortedByDescending { it.createdAt }
val limit = filters.mapNotNull { it.limit }.maxOrNull()
return limit?.let { merged.take(it) } ?: merged
val limited = limit?.let { merged.take(it) } ?: merged
return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) }
}
/**
@@ -279,7 +281,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."))
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
import android.util.Base64
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
import com.vitorpamplona.quartz.nip01Core.core.Address
@@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import okhttp3.Authenticator
import okhttp3.Call
import okhttp3.Callback
import okhttp3.CookieJar
import okhttp3.Dns
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.io.InterruptedIOException
import java.net.InetAddress
import java.net.URLDecoder
import java.nio.ByteBuffer
import java.nio.charset.CodingErrorAction
import java.util.concurrent.TimeUnit
/**
* Fetches a resource URL on an applet's behalf — the applet has no direct network
@@ -63,41 +81,100 @@ class NappletResourceFetcher(
private val account: Account,
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
) {
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
/** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
suspend fun fetch(
url: String,
coordinate: String,
): NappletResource? =
): NappletResourceResult =
withContext(Dispatchers.IO) {
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
NappletNetworkRegistry.awaitReady()
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
when {
url.startsWith("data:") -> decodeDataUrl(url)
url.startsWith("https://") -> {
runCatching {
client
.newCall(
Request
.Builder()
.url(url)
.get()
.build(),
).execute()
.use { r ->
if (!r.isSuccessful) return@withContext null
val body = r.body.bytes()
val type = r.header("Content-Type") ?: "application/octet-stream"
NappletResource(body, type)
}
}.getOrNull()
url.startsWith("nostr:") ->
resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
url.startsWith("https://") || url.startsWith("blossom:") -> {
// Route like the applet's own page: locked napplets stay on Tor. The derived
// client removes ambient cookies/auth and validates DNS before every hop.
NappletNetworkRegistry.awaitReady()
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
}
url.startsWith("blossom:") -> fetchBlossom(url, client)
url.startsWith("nostr:") -> resolveNostr(url)
else -> null
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
}
}
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
baseClient
.newBuilder()
.followRedirects(false)
.followSslRedirects(false)
.cache(null)
.cookieJar(CookieJar.NO_COOKIES)
.authenticator(Authenticator.NONE)
.proxyAuthenticator(Authenticator.NONE)
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.dns(
Dns { hostname ->
baseClient.dns.lookup(hostname).also { addresses ->
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
throw BlockedResourceException("Resolved address is not public.")
}
}
},
).addNetworkInterceptor { chain ->
chain.proceed(
chain
.request()
.newBuilder()
.removeHeader("Authorization")
.removeHeader("Cookie")
.removeHeader("Proxy-Authorization")
.build(),
)
}.build()
private suspend fun fetchHttps(
url: String,
client: OkHttpClient,
): NappletResourceResult {
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
repeat(MAX_REDIRECTS + 1) { hop ->
try {
client
.newCall(
Request
.Builder()
.url(current)
.get()
.build(),
).await()
.use { response ->
if (response.isRedirect) {
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
return@repeat
}
if (response.code == 404) return failure(ERROR_NOT_FOUND)
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
return classify(body)
}
} catch (e: BlockedResourceException) {
return failure(ERROR_BLOCKED, e.message)
} catch (_: InterruptedIOException) {
return failure(ERROR_TIMEOUT)
} catch (_: Exception) {
return failure(ERROR_NETWORK)
}
}
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
}
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
/**
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
* carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to
@@ -144,7 +221,7 @@ class NappletResourceFetcher(
val relays = account.homeRelays.flow.value
if (relays.isEmpty()) return null
return runCatching {
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
}.getOrDefault(emptyList())
.maxByOrNull { it.createdAt }
}
@@ -155,65 +232,205 @@ class NappletResourceFetcher(
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
* serves it.
*/
private fun fetchBlossom(
private suspend fun fetchBlossom(
url: String,
client: OkHttpClient,
): NappletResource? {
val hash =
url
.removePrefix("blossom://")
.removePrefix("blossom:")
.substringBefore('/')
.substringBefore('?')
.trim()
.lowercase()
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
): NappletResourceResult {
if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
val servers =
account.blossomServers
.getBlossomServersList()
?.servers()
.orEmpty()
var sawHashMismatch = false
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
val bytes =
runCatching {
client
.newCall(
Request
.Builder()
.url(candidate)
.get()
.build(),
).execute()
.use { r ->
if (r.isSuccessful) r.body.bytes() else null
}
}.getOrNull() ?: continue
if (StaticSiteResolver.verify(bytes, hash)) {
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream")
when (val fetched = fetchHttps(candidate, client)) {
is NappletResourceResult.Success -> {
if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
sawHashMismatch = true
continue
}
return fetched
}
is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
}
}
return null
if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
}
private suspend fun Call.await(): Response =
suspendCancellableCoroutine { continuation ->
continuation.invokeOnCancellation { cancel() }
enqueue(
object : Callback {
override fun onFailure(
call: Call,
e: IOException,
) {
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
}
override fun onResponse(
call: Call,
response: Response,
) {
if (continuation.isActive) {
continuation.resumeWith(Result.success(response))
} else {
response.close()
}
}
},
)
}
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
private fun decodeDataUrl(url: String): NappletResource? {
private fun decodeDataUrl(url: String): NappletResourceResult {
val comma = url.indexOf(',')
if (comma < 0) return null
if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
val meta = url.substring("data:".length, comma)
val data = url.substring(comma + 1)
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
val isBase64 = meta.endsWith(";base64")
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" }
val declaredType =
meta
.removeSuffix(";base64")
.substringBefore(';')
.ifEmpty { "text/plain" }
.lowercase()
val bytes =
if (isBase64) {
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
} else {
URLDecoder.decode(data, "UTF-8").encodeToByteArray()
runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
}
return NappletResource(bytes, contentType)
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
return classify(bytes, declaredType)
}
private fun classify(
bytes: ByteArray,
declaredType: String? = null,
): NappletResourceResult {
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
val sniffed = sniffContentType(bytes)
val type =
when {
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
declaredType == "application/json" && isJson(bytes) -> "application/json"
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
else -> null
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
return success(NappletResource(bytes, type))
}
private fun looksLikeSvg(bytes: ByteArray): Boolean {
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
return prefix.contains("<svg")
}
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
private fun isPlainText(bytes: ByteArray): Boolean =
runCatching {
Charsets.UTF_8
.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes))
}.isSuccess && bytes.none { it == 0.toByte() }
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
private fun failure(
error: String,
message: String? = null,
): NappletResourceResult = NappletResourceResult.Failure(error, message)
private fun readBounded(input: java.io.InputStream): ByteArray? {
input.use { source ->
val output = ByteArrayOutputStream()
val buffer = ByteArray(8 * 1024)
var total = 0
while (true) {
val read = source.read(buffer)
if (read < 0) break
total += read
if (total > MAX_RESOURCE_BYTES) return null
output.write(buffer, 0, read)
}
return output.toByteArray()
}
}
companion object {
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
internal fun isPublicAddress(address: InetAddress): Boolean {
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
return false
}
val bytes = address.address
if (bytes.size == 4) {
val first = bytes[0].toInt() and 0xff
val second = bytes[1].toInt() and 0xff
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
if (first == 0 || first >= 224) return false
if (first == 100 && second in 64..127) return false
if (first == 192 && second == 0) return false
if (first == 198 && second in 18..19) return false
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
} else if (bytes.size == 16) {
val first = bytes[0].toInt() and 0xff
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
if (
first == 0x20 &&
(bytes[1].toInt() and 0xff) == 0x01 &&
(bytes[2].toInt() and 0xff) == 0x0d &&
(bytes[3].toInt() and 0xff) == 0xb8
) {
return false // 2001:db8::/32 documentation range
}
}
return true
}
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
private const val FETCH_TIMEOUT_SECONDS = 30L
private const val MAX_REDIRECTS = 5
private const val MIME_PREFIX_BYTES = 8 * 1024
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
private const val ERROR_INVALID_REQUEST = "invalid-request"
private const val ERROR_NOT_FOUND = "not-found"
private const val ERROR_BLOCKED = "blocked-by-policy"
private const val ERROR_TIMEOUT = "timeout"
private const val ERROR_TOO_LARGE = "too-large"
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
private const val ERROR_DECODE_FAILED = "decode-failed"
private const val ERROR_NETWORK = "network-error"
private val SHA256 = Regex("^[0-9a-f]{64}$")
private val ALLOWED_SNIFFED_TYPES =
setOf(
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"audio/ogg",
"video/mp4",
)
}
private class BlockedResourceException(
message: String,
) : java.io.IOException(message)
}
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
val listener =
object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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,44 @@ 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() }
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
// sits in the shade's Silent section next to the low-importance content kinds
// (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into
// one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that
// has it, making the whole bundle un-swipeable. Giving this one a group of its own
// would not help: a group with a summary but no children, or a child with no summary,
// is force-grouped just the same. What keeps content notifications out of that bundle
// is that they always post their own group summary (see NotificationUtils), which
// leaves this the only ungrouped silent notification we post — one is below the
// threshold, so no bundle is formed and nothing gets stapled to it.
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)
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -54,13 +56,35 @@ class NotificationReplyReceiver : BroadcastReceiver() {
intent: Intent,
) {
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
// Whatever the action, the user is done with this notification, so record it before
// doing anything else. An enrichment window may still be open on the event (up to
// 25s from the first post), and it re-posts the notification every time metadata
// lands — without this the notification the user just dealt with comes back, and the
// enricher keeps a relay subscription and a wakelock alive for it until the window
// elapses. Replies mark it after the send succeeds instead, so a failure leaves the
// notification to enrich and retry.
val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID)
if (intent.action != NotificationUtils.REPLY_ACTION &&
intent.action != NotificationUtils.PUBLIC_REPLY_ACTION &&
intent.action != NotificationUtils.MARMOT_REPLY_ACTION
) {
eventId?.let { NotificationUtils.markDismissed(it) }
}
val notificationManager =
ContextCompat.getSystemService(context, NotificationManager::class.java)
as NotificationManager
when (intent.action) {
NotificationUtils.MARK_READ_ACTION -> {
notificationManager.cancel(notificationId)
notificationManager.cancelAndPrune(notificationId)
}
// The user swiped the notification away. It is already gone; all that is left
// is to take its group summary with it when it was the last child.
NotificationUtils.DISMISS_ACTION -> {
notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId)
}
NotificationUtils.REPLY_ACTION -> {
@@ -78,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
runOnRelay(notificationManager, notificationId) {
runOnRelay(notificationManager, notificationId, eventId) {
sendReply(accountNpub, members, replyText)
}
}
@@ -95,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
runOnRelay(notificationManager, notificationId) {
runOnRelay(notificationManager, notificationId, eventId) {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
@@ -114,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId) {
runOnRelay(notificationManager, notificationId, eventId) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
@@ -124,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
private fun runOnRelay(
notificationManager: NotificationManager,
notificationId: Int,
eventId: String?,
block: suspend () -> Unit,
) {
val pendingResult = goAsync()
@@ -138,7 +163,8 @@ class NotificationReplyReceiver : BroadcastReceiver() {
try {
block()
notificationManager.cancel(notificationId)
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.cancelAndPrune(notificationId)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
@@ -189,7 +215,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(
@@ -70,8 +70,16 @@ object NotificationUtils {
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION"
const val KEY_REPLY_TEXT = "key_reply_text"
const val KEY_NOTIFICATION_ID = "key_notification_id"
/**
* Hex id of the event this notification was posted for, carried on every action
* and on the delete intent so the receiver can mark it dismissed. Distinct from
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
*/
const val KEY_EVENT_ID = "key_event_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
@@ -82,16 +90,27 @@ object NotificationUtils {
const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
private const val REPLY_SUMMARY_ID_BASE = 0x50000
// Event ids the user has just read/dismissed in-app. The enrichment path
// re-posts a notification as metadata arrives; without this guard a
// notification the user already dismissed would be resurrected seconds later
// when its author's kind:0 lands. Keyed by the event id string (not the
// hashCode) so distinct events can't collide. Entries self-expire after a
// window comfortably longer than the 25s enrichment window.
/**
* Every group key this object posts under starts with this. Used to tell our own
* summaries apart from the ones the system creates when it force-groups us (those
* live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the
* platform over a bundle it owns.
*/
private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst."
// Event ids the user is done with. The enrichment path re-posts a notification
// as metadata arrives; without this guard a notification the user already got
// rid of would be resurrected seconds later when its author's kind:0 lands, and
// the enricher would go on holding a relay window and a wakelock open for it.
// Every way a user can be done with a notification has to record here — reading
// the event in-app, swiping the notification away, "mark as read", and replying
// inline — or that path leaks the resurrection. Keyed by the event id string
// (not the hashCode) so distinct events can't collide. Entries self-expire after
// a window comfortably longer than the 25s enrichment window.
private const val DISMISS_GUARD_MS = 90_000L
private val recentlyDismissed = ConcurrentHashMap<String, Long>()
private fun markDismissed(eventId: String) {
fun markDismissed(eventId: String) {
val now = SystemClock.elapsedRealtime()
recentlyDismissed[eventId] = now + DISMISS_GUARD_MS
if (recentlyDismissed.size > 256) {
@@ -218,6 +237,7 @@ object NotificationUtils {
.setPriority(category.priority())
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroup(groupKey)
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setWhen(time * 1000)
@@ -236,11 +256,11 @@ object NotificationUtils {
}
if (inlineReply != null) {
builder.addAction(publicReplyAction(applicationContext, notId, inlineReply))
builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply))
}
notify(notId, builder.build())
sendGroupSummary(category, groupKey, summaryId, applicationContext)
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
}
// ---------------------------------------------------------------------
@@ -335,20 +355,21 @@ object NotificationUtils {
.setPriority(category.priority())
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setGroup(groupKey)
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setWhen(time * 1000)
when (replyAction) {
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction))
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction))
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) }
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) }
}
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId))
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id))
notify(notId, builder.build())
sendGroupSummary(category, groupKey, summaryId, applicationContext)
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
}
// ---------------------------------------------------------------------
@@ -370,6 +391,38 @@ object NotificationUtils {
)
}
/**
* Fires when the user swipes this notification away (or hits "Clear all"), so the
* group summary can follow its last child out.
*
* We can't rely on the shade to take the summary with it: SystemUI hides a group
* with a single child and renders that child at the top level
* (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no
* longer counts as "the only child in its group", so dismissing it leaves our
* summary behind. A childless summary is not harmless — SystemUI promotes it into
* the shade on its own, and the system force-groups it
* (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we
* post summaries to stay out of.
*/
private fun dismissIntent(
applicationContext: Context,
notId: Int,
eventId: String,
): PendingIntent {
val intent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = DISMISS_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_EVENT_ID, eventId)
}
return PendingIntent.getBroadcast(
applicationContext,
notId + 2,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private fun replyRemoteInput(applicationContext: Context): RemoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
@@ -399,12 +452,14 @@ object NotificationUtils {
private fun dmReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
action: ReplyAction.Dm,
): NotificationCompat.Action {
val intent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
this.action = REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_EVENT_ID, eventId)
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers)
}
@@ -414,12 +469,14 @@ object NotificationUtils {
private fun marmotReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
action: ReplyAction.Marmot,
): NotificationCompat.Action {
val intent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
this.action = MARMOT_REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_EVENT_ID, eventId)
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId)
action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) }
@@ -431,12 +488,14 @@ object NotificationUtils {
private fun publicReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
target: InlineReplyTarget,
): NotificationCompat.Action {
val intent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = PUBLIC_REPLY_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_EVENT_ID, eventId)
putExtra(KEY_ACCOUNT_NPUB, target.accountNpub)
putExtra(KEY_TARGET_EVENT_ID, target.targetEventId)
}
@@ -446,11 +505,13 @@ object NotificationUtils {
private fun markReadAction(
applicationContext: Context,
notId: Int,
eventId: String,
): NotificationCompat.Action {
val markReadIntent =
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
action = MARK_READ_ACTION
putExtra(KEY_NOTIFICATION_ID, notId)
putExtra(KEY_EVENT_ID, eventId)
}
val markReadPendingIntent =
PendingIntent.getBroadcast(
@@ -549,16 +610,33 @@ object NotificationUtils {
// Group summaries, dedup, dismissal
// ---------------------------------------------------------------------
/**
* Posts (or refreshes) our own summary for [groupKey].
*
* The summary goes up with the **first** child, not once a second one shows up.
* Android 16 counts a group child whose summary is missing as ungrouped
* (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the
* package's per-section aggregate bundle, next to every other ungrouped
* notification in the same shade section. The bar is low: `config_autoGroupAtCount`
* is 2, so a single summary-less child plus one other ungrouped notification is a
* bundle. The always-on relay service is exactly that other notification — ongoing
* and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW
* kinds (reactions and reposts), so one lone repost would end up bundled with it.
* The bundle then refuses to swipe away, because the system's aggregate summary
* inherits FLAG_ONGOING_EVENT from any child carrying it — and the service
* notification always does.
*
* Providing the summary from the start keeps the group ours and the system leaves
* it alone. It costs nothing visually: the shade hides any group with fewer than
* two children and shows the child on its own.
*/
private fun NotificationManager.sendGroupSummary(
category: NotificationCategory,
groupKey: String,
summaryId: Int,
time: Long,
applicationContext: Context,
) {
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
if (activeCount < 2) return
val summaryBuilder =
NotificationCompat
.Builder(applicationContext, category.channelId(applicationContext))
@@ -566,8 +644,16 @@ object NotificationUtils {
.setColor(category.color)
.setGroup(groupKey)
.setGroupSummary(true)
// The children do the alerting. Without this the summary would buzz on
// its own the moment it starts going up alongside the first child.
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
// Pinned to the child's event time rather than left to default to "now".
// The summary is re-posted on every one of the enrichment path's re-renders,
// and a fresh timestamp each time would keep re-sorting the group in the
// shade while the user is looking at it.
.setWhen(time * 1000)
.setStyle(
NotificationCompat
.InboxStyle()
@@ -604,17 +690,53 @@ object NotificationUtils {
// items), so bail out before touching anything when nothing is posted for it.
if (activeNotifications.none { it.id == notId }) return
cancel(notId)
cancelChildlessGroupSummaries()
cancelAndPrune(notId)
}
private fun NotificationManager.cancelChildlessGroupSummaries() {
/**
* Cancels [notId] and drops the group summary it leaves behind, if it was the last
* child. Use this instead of a bare [NotificationManager.cancel] for anything we
* posted through [postStandard] / [postConversation] — every one of those is a
* group child with a summary above it.
*/
fun NotificationManager.cancelAndPrune(notId: Int) {
cancel(notId)
cancelChildlessGroupSummaries(alreadyGone = notId)
}
/**
* Drops our summaries that no longer have any children.
*
* [alreadyGone] is the id of a notification cancelled moments ago: both
* [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous,
* so [NotificationManager.activeNotifications] can still be listing it and would
* otherwise keep its summary alive forever.
*
* Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate
* summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one
* only makes the platform rebuild it.
*/
fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) {
val active: Array<StatusBarNotification> = activeNotifications
// Collect the groups that still have a child in one pass, then cancel the
// summaries not in that set. Every child now ships with a summary, so this list
// is about twice as long as it used to be and the pairwise scan it replaces grew
// four-fold. Membership is decided by the summary flag rather than by comparing
// ids, which is also what makes it correct when a child's id happens to equal the
// summary's.
val groupsWithChildren = HashSet<String>(active.size)
for (child in active) {
if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue
if (child.id == alreadyGone) continue
child.notification.group?.let { groupsWithChildren.add(it) }
}
for (summary in active) {
if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue
val group = summary.notification.group ?: continue
val hasChildren = active.any { it.id != summary.id && it.notification.group == group }
if (!hasChildren) cancel(summary.id)
if (!group.startsWith(OWN_GROUP_PREFIX)) continue
if (group !in groupsWithChildren) cancel(summary.id)
}
}
@@ -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
}
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -49,6 +50,14 @@ class RelayProxyClientConnector(
val torStatus: StateFlow<TorServiceStatus>,
val client: INostrClient,
val scope: CoroutineScope,
/**
* Called with the cause every time this connector *decides* to reconnect, so the
* usage ledger can attribute relay churn without this class knowing where the
* counters go. Causes are the `UsageKeys.TRIGGER_*` constants — see
* [UsageKeys.relayTrigger] for why these are an upper bound rather than a count
* of reconnects actually performed.
*/
val onTrigger: (String) -> Unit = {},
) {
data class RelayServiceInfra(
val evaluator: TorRelayEvaluation,
@@ -138,6 +147,9 @@ class RelayProxyClientConnector(
infra.connectivity is ConnectivityStatus.Off -> {
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
if (client.isActive()) {
// Counted inside the guard: the upstream combine() re-emits Off
// repeatedly and only this branch does any work.
onTrigger(UsageKeys.TRIGGER_OFF)
client.disconnect()
}
if (infra.torStatus is TorServiceStatus.Active) {
@@ -156,6 +168,7 @@ class RelayProxyClientConnector(
}
// only calls this if the client is not active. Otherwise goes to the else below
onTrigger(UsageKeys.TRIGGER_COLD_START)
client.connect()
lastNetworkId = networkId
lastTorSettings = torSettings
@@ -211,10 +224,26 @@ class RelayProxyClientConnector(
Log.d("ManageRelayServices") {
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
}
// The expensive branch, and the one the churn investigation is
// aimed at: a full teardown re-dials the whole pool and replays
// every REQ.
//
// Only this cause is counted here, so a wifi<->cellular handoff —
// which mints a new network handle AND rebuilds the OkHttp clients
// off the metered bit — is booked as netid alone. relay.trigger.transport
// therefore undercounts exactly the case one would most want it for;
// read it as "transport changed WITHOUT the handle changing".
onTrigger(UsageKeys.TRIGGER_NETID)
// Full teardown: disconnect() drops the dead sockets AND clears each
// relay's backoff, so the new network starts from a clean slate.
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
} else {
// Non-exclusive: count each independently so the report shows
// which combination fired.
if (transportChanged) onTrigger(UsageKeys.TRIGGER_TRANSPORT)
if (torPolicyChanged) onTrigger(UsageKeys.TRIGGER_TOR_POLICY)
if (classificationChanged) onTrigger(UsageKeys.TRIGGER_CLASSIFICATION)
val freshStart = transportChanged || torPolicyChanged
if (freshStart) {
// The failures behind the current backoffs were measured against a
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -112,7 +112,7 @@ class DmRelayDiagnosticsLogger(
Log.d(TAG) { "[+${at()}ms] REQ -> ${relay.url.url} success=$success ${cmdStr.take(400)}" }
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -0,0 +1,46 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
* Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account
* attribution for [AccountScopedQuery] keys.
*
* The commons base is account-agnostic (attribution defaults to null) so it can live in
* commonMain. Query states that carry an [Account] (home feed, channels, notifications, …)
* subclass this so their single-account REQs still show up attributed in "Active Relay
* Subscriptions".
*
* 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.
*/
abstract class AccountScopedSingleSubNoEoseCacheEoseManager<T>(
client: INostrClient,
allKeys: () -> Set<T>,
invalidateAfterEose: Boolean = false,
) : SingleSubNoEoseCacheEoseManager<T>(client, allKeys, invalidateAfterEose) {
override 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.amethyst.service.relays.EOSEByKey
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -76,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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
@@ -88,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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
@@ -75,7 +77,7 @@ abstract class PerUserEoseManager<T>(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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
}
@@ -78,7 +78,7 @@ class NotifyCoordinator(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -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
@@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssembler
@@ -168,6 +168,9 @@ class RelaySubscriptionsCoordinator(
val chess = ChessFilterAssembler(client)
val polls = PollsFilterAssembler(client)
// Votes for the poll whose results screen is open.
val pollResponses = PollResponsesFilterAssembler(client)
val pictures = PicturesFilterAssembler(client)
val workouts = WorkoutsFilterAssembler(client)
val gitRepositories = GitRepositoriesFilterAssembler(client)
@@ -204,10 +207,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.
@@ -234,6 +233,7 @@ class RelaySubscriptionsCoordinator(
video,
discovery,
polls,
pollResponses,
pictures,
workouts,
gitRepositories,
@@ -279,7 +279,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)
@@ -114,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
newEose(TimeUtils.now(), relay, forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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)
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.mapOfSet
fun pickRelaysToLoadUsers(
users: Set<User>,
@@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers(
return pickRelaysToLoadUsers(
users,
LocalCache.relayHints,
indexRelays - cannotConnectRelays,
homeRelays - cannotConnectRelays,
searchRelays - cannotConnectRelays,
@@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers(
hasTried,
)
}
fun pickRelaysToLoadUsers(
users: Set<User>,
indexRelays: Set<NormalizedRelayUrl>,
homeRelays: Set<NormalizedRelayUrl>,
searchRelays: Set<NormalizedRelayUrl>,
connected: Set<NormalizedRelayUrl>,
commonRelays: Set<NormalizedRelayUrl>,
cannotConnectRelays: Set<NormalizedRelayUrl>,
hasTried: EOSEAccountFast<User>,
): Map<NormalizedRelayUrl, Set<HexKey>> =
mapOfSet {
users.forEachIndexed { _, key ->
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
val outbox = key.authorRelayList()?.writeRelaysNorm()
if (!outbox.isNullOrEmpty()) {
// If there is a home, get from it.
// if it tried all outbox relays, stop.
// the UserWatch will take over from here.
val leftToTry = (outbox - tried)
leftToTry.forEach {
add(it, key.pubkeyHex)
}
} else {
// if not, tries hints first.
val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex)
val leftToTryOnHints = hints - tried
leftToTryOnHints.forEach {
add(it, key.pubkeyHex)
}
// if there are only a few hints, broadens the search
if (leftToTryOnHints.size < 3) {
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val indexRelaysLeftToTry =
(indexRelays - tried).sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val homeRelaysLeftToTry =
(homeRelays - tried).sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}
// picks one at random to avoid overloading these relays
if (users.size > 300) {
if (indexRelaysLeftToTry.size >= 2) {
add(indexRelaysLeftToTry[0], key.pubkeyHex)
add(indexRelaysLeftToTry[1], key.pubkeyHex)
} else if (indexRelaysLeftToTry.size == 1) {
add(indexRelaysLeftToTry.first(), key.pubkeyHex)
}
homeRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
} else {
indexRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
homeRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
}
if (indexRelaysLeftToTry.size < 2) {
val searchRelaysLeftToTry = searchRelays - tried
searchRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
val connectedRelaysLeftToTry =
(connected - tried)
.sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}.take(100)
// picks one at random to avoid overloading these relays
if (users.size > 300) {
connectedRelaysLeftToTry.take(20).forEach {
add(it, key.pubkeyHex)
}
} else {
connectedRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
}
if (searchRelaysLeftToTry.size < 2) {
// This creates a pre-deterministic order of the array such that
// if this function is called twice, it returns the same arrays
// which gets ignored by the relay client if we send it twice
val allRelaysLeftToTry =
(commonRelays - tried)
.sortedBy { relay ->
key.pubkeyHex.hashCode() xor relay.url.hashCode()
}.take(100)
allRelaysLeftToTry.forEach {
add(it, key.pubkeyHex)
}
}
}
}
}
}
}
@@ -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")))
}
}

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