Merge remote-tracking branch 'upstream/main' into test/polls-results

This commit is contained in:
Vitor Pamplona
2026-08-11 19:34:03 -04:00
510 changed files with 31077 additions and 3353 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.
+107 -20
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:
@@ -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"
@@ -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:
@@ -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:
@@ -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
+29 -4
View File
@@ -49,9 +49,17 @@ 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
@@ -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
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* 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
@@ -723,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
@@ -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(
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.defaults.Constants
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
import com.vitorpamplona.amethyst.commons.model.IAccount
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
@@ -59,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -150,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.quartz.buzz.threading.buzzThread
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
@@ -285,6 +289,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
@@ -353,7 +358,8 @@ class Account(
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
) : IAccount {
) : IAccount,
UserFinderAccount {
private var userProfileCache: User? = null
override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
@@ -365,6 +371,34 @@ class Account(
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsers.flow.value.hiddenUsersHashCodes
override val spammersHashCodes: Set<Int> get() = hiddenUsers.flow.value.spammersHashCodes
// UserFinderAccount — narrow, read-only relay-hint view used by the shared
// per-user metadata + per-note event finders (moved to commons). Snapshot
// getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
override fun followPlusAllMineWithSearchRelays(): Set<NormalizedRelayUrl> = followPlusAllMineWithSearch.flow.value
override fun commonRelays(): Set<NormalizedRelayUrl> = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays }
override fun cardHomeRelays(): Set<NormalizedRelayUrl> = homeRelays.flow.value
override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value
override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value
override fun declaredFollowsByOutboxRelay(): Map<NormalizedRelayUrl, Set<HexKey>> = declaredFollowsPerOutboxRelay.value
val userMetadata = UserMetadataState(signer, cache, scope, settings)
// Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is
@@ -955,6 +989,9 @@ class Account(
*/
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
/** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */
fun applyHiddenDrawerItems(items: Set<NavBarItem>): Boolean = settings.changeHiddenDrawerItems(items)
suspend fun toggleChatroomPin(room: ChatroomKey) {
settings.toggleChatroomPin(room)
sendNewAppSpecificData()
@@ -3559,6 +3596,9 @@ class Account(
refreshConcordChannelIndex()
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
// A promotion to staff delivers the Control Plane write key inside the Grant
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
// found by re-resolving the invite link we joined through. Rate-limited internally.
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
@@ -22,30 +22,43 @@ package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
import com.vitorpamplona.quartz.concord.crypto.GroupKey
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
@@ -55,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.util.concurrent.ConcurrentHashMap
/** Name of the default Concord community Admin role minted by "Make admin". */
@@ -68,6 +84,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
*/
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
/**
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
*
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
* Raising it raises the cost of the attack it exists to bound, not the safety.
*/
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
/**
* Concord (encrypted communities) orchestration for an [Account]: join/create/
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
@@ -138,6 +163,10 @@ class AccountConcordActions(
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
// The creator is the founding staff member (CORD-02 §2): it keeps the write
// secret and publishes only the derived pubkey to everyone else.
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = relayUrls,
name = name,
addedAt = TimeUtils.now() * 1000,
@@ -146,6 +175,138 @@ class AccountConcordActions(
return community.communityIdHex
}
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
/**
* This account's Invite List (kind 13303): the creator's private, self-encrypted record of every
* link they minted (`token` + `signer_sk` per entry).
*
* Returns **null** when the list could not be read — no relay answered, or the signer refused
* the decrypt — and an empty document only when the account genuinely has no list yet. Callers
* must not conflate the two: republishing an "empty" list over this replaceable coordinate
* destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated.
*
* Read on the account's OUTBOX relays, never a community's: the coordinate is
* (13303, me, "") — one list for the whole account — so scoping it per community would fork it
* into divergent versions that the newest-wins rule then silently collapses.
*
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is
* bookkeeping the user never sees, needed only at mint and at rotation.
*/
private suspend fun readConcordInviteList(): ConcordInviteListDocument? {
val relays = account.outboxRelays.flow.value
if (relays.isEmpty()) return null
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey))
// Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay
// served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle
// timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes
// the signer_sk of every link it failed to read, so the two must be told apart.
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
val events =
account.client.fetchAllWithHooks(
filters = relays.associateWith { listOf(filter) },
doneOut = reasons,
) { _, _ -> true }
val newest =
events
.mapNotNull { it.second as? ConcordInviteListEvent }
// Filter by kind BEFORE picking the newest: taking the newest of anything and then
// casting means one stray event at this coordinate reads as "unreadable" forever.
.maxByOrNull { it.createdAt }
?: return if (reasons.anyRelayServed()) {
ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one
} else {
null // nobody answered; we know nothing about what is published
}
return newest.decrypt(account.signer)
}
/**
* Merges [patch] into the published Invite List and republishes it, returning whether it landed.
*
* Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is
* replaceable, so publishing a patch-only document over an unread list deletes every other
* link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next
* rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is
* enough to trigger that, which is exactly how the kind-13302 community list was once emptied.
*/
private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean {
val publishTo = account.outboxRelays.flow.value
if (publishTo.isEmpty()) return false
val base =
readConcordInviteList() ?: run {
Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" }
return false
}
// publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event
// and never reports acceptance — so a `runCatching { publish(); true }` is true whenever
// local signing worked, and every caller's "did the record land?" gate becomes decorative.
return runCatching {
account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo)
}.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false)
}
/**
* Re-posts every live link this account minted for [entry]'s community at its own coordinate,
* carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
* link signer, so this moves the link behind the same URL instead of orphaning it at a dead
* epoch — which is the whole premise stranded recovery rests on.
*
* [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow
* decrypts asynchronously, so reading it straight after adopting a new root yields the OLD
* epoch and would re-mint every link onto the epoch we just left.
*
* Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link
* fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A
* coordinate whose newest event is a revocation tombstone is left alone: re-posting a live
* bundle over it would silently un-revoke the link.
*/
private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return 0
val list = readConcordInviteList() ?: return 0
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
val now = TimeUtils.now()
// An elapsed or retired link can no longer be joined; re-posting it would only resurrect a
// dead URL at a live epoch.
val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned }
if (links.isEmpty()) return 0
// One REQ for every link's bundle rather than a round trip each. This runs inside the
// user-visible Refounding, and a serial fetch per link makes a removal take time linear in
// how many links the creator ever minted, each able to wait out its own idle timeout.
val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() }
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) })
val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() }
return coroutineScope {
byAuthor
.map { (author, link) ->
async {
runCatching {
val token = link.token.hexToByteArray()
// Classify per coordinate, never over the pooled set: one link's newer
// revocation tombstone must not decide another link's status.
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false
val moved =
current.invite.copy(
communityRoot = entry.root,
rootEpoch = entry.rootEpoch,
controlPk = entry.controlPk,
relays = entry.relays,
)
// Confirmed: a link counted as moved but never stored is a link its
// holders can no longer redeem, reported as a success.
account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
}.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false)
}
}.awaitAll()
.count { it }
}
}
/**
* Mint a shareable invite link for a joined community and publish its
* kind-33301 public bundle to the community relays. Returns the `…/invite/…`
@@ -159,6 +320,21 @@ class AccountConcordActions(
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return null
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
// which made minting the one moderation-free action in the app: a member the owner had just
// banned could tap the invite button and hand out a working link to the community they were
// removed from, and every account they invited arrived as a fresh un-banned npub.
//
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
// mint before the session exists — the session is built asynchronously off the joined list,
// and requiring it here would have made the owner's own invite button fail on a cold start.
// Everyone else needs the folded roster, so no session means no invite.
val session = account.concordSessions.sessionFor(communityId)
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
val invite =
ConcordActions.inviteFor(
communityIdHex = entry.id,
@@ -168,14 +344,110 @@ class AccountConcordActions(
rootEpoch = entry.rootEpoch,
name = entry.name,
relays = entry.relays,
// The joiner can never derive the Control Plane address, so the bundle carries
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
controlPk = entry.controlPk,
)
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
// Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk`
// was never stored can never be refreshed, so the next Refounding orphans it and everyone
// holding it is stranded — with nothing to have warned them. Failing the mint is the honest
// outcome; a stored entry for a link nobody received is harmless by comparison.
if (!publishConcordInviteList(
ConcordInviteListDocument(
entries =
listOf(
ConcordInviteListEntry(
token = minted.token.toHexKey(),
signerSk = minted.linkSignerPrivKey.toHexKey(),
communityId = entry.id,
url = minted.url,
createdAt = TimeUtils.now(),
),
),
),
)
) {
Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" }
return null
}
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
return minted.url
}
/**
* Every link this account minted for [communityId] that is still live, newest first — the
* backing list for the invite-links screen.
*
* Null means the list could not be read (no relay answered, or the signer refused the decrypt),
* which the UI must show as an error rather than as "you have no links": telling a creator their
* leaked link doesn't exist is worse than telling them we couldn't check.
*
* Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList]
* already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window
* between our revoke and the next merge.
*/
suspend fun listConcordInviteLinks(communityId: String): List<ConcordInviteListEntry>? {
val list = readConcordInviteList() ?: return null
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
return list.entries
.filter { it.communityId == communityId && it.token !in tombstoned }
.sortedByDescending { it.createdAt }
}
/**
* Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then
* records the retirement in the kind-13303 list. Returns false if the link could not be retired.
*
* No community permission is checked, deliberately. The coordinate is authored by the link
* signer, whose secret only the creator holds, so revoking is an act on your own key rather than
* on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer
* retire the links they had already handed out, which is precisely when they most need to.
*
* The wire tombstone goes first and the list second. That is the inverse of minting and it is
* deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a
* tombstoned token's entry terminally, so recording first and then failing to publish would
* leave the link live with its signer gone and no way left to retire it. A failed list write is
* recoverable — the link is already dead on the wire, and the refresh path re-mints only a
* coordinate that still resolves Live.
*/
suspend fun revokeConcordInvite(
communityId: String,
token: String,
): Boolean {
if (!account.isWriteable()) return false
val entry =
account.concordChannelList.liveCommunities.value
.firstOrNull { it.id == communityId } ?: return false
val link =
readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId }
?: run {
Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" }
return false
}
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
if (relays.isEmpty()) return false
// Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a
// tombstone no relay stored — and the list write below would then drop this entry on merge,
// destroying the only `signer_sk` that could ever retire the link while the link stays live.
val published =
runCatching {
account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays)
}.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false)
if (!published) return false
if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) {
// The link is already dead on the wire, so this is bookkeeping we can retry rather than a
// failed revocation. Reported as success for exactly that reason.
Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" }
}
return true
}
/** Drop a joined Concord community from the private kind-13302 list by its id. */
suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId))
@@ -238,6 +510,42 @@ class AccountConcordActions(
return ConcordInviteResult.Joined(bundle.communityId)
}
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
// unlock token forever — so without this the rotation meant to expel them hands them the new
// keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the
// other door into the same room.
//
// Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields
// the root, and no verdict means no join. Two things make that safe to insist on rather than
// a way to brick valid invites:
//
// - the plane is fetched over the SAME relays that just served the bundle, not the relay
// list inside the bundle alone, which can be stale (a moved relay, a link minted before a
// relay change) and would otherwise refuse a community we can plainly reach;
// - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing
// older ban edition fails the gate open — it re-admits the very account it exists to
// refuse — so the one direction we must not economise on is completeness.
val joinKeys =
ConcordActions.controlPlaneKeys(
communityRoot = bundle.communityRoot.hexToByteArray(),
communityId = bundle.communityId.hexToByteArray(),
rootEpoch = bundle.rootEpoch,
controlPk = bundle.controlPk,
)
// Union, not `ifEmpty`: the relays that served the bundle are known-good for this community,
// and the bundle's own list is the one that goes stale.
val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays
val planeWraps = mutableListOf<Event>()
account.client.fetchAllPagesFromPool(
filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) },
) { event, _ -> planeWraps.add(event) }
val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys)
if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) {
return ConcordInviteResult.Banned
}
val entry =
ConcordCommunityListEntry(
id = bundle.communityId,
@@ -245,6 +553,9 @@ class AccountConcordActions(
ownerSalt = bundle.ownerSalt,
root = bundle.communityRoot,
rootEpoch = bundle.rootEpoch,
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
// community is still pre-split, so we fold it at the legacy address.
controlPk = bundle.controlPk,
relays = bundle.relays,
name = bundle.name,
addedAt = TimeUtils.now() * 1000,
@@ -412,7 +723,17 @@ class AccountConcordActions(
channelIdHex: String,
) {
if (!account.isWriteable()) return
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
val session = account.concordSessions.sessionFor(communityId) ?: return
// A ban hides every message we send, so continuing to announce that we are typing them is
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
if (session.state.value
?.authority
?.isBanned(account.signer.pubKey) == true
) {
return
}
val entry = session.entry
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
@@ -451,6 +772,67 @@ class AccountConcordActions(
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
// target is simply dropped on fold. Owner-authored calls always take effect.
/**
* The Control Plane keys for a moderation write, or null when this account cannot
* publish there: on a split epoch only `control_root` holders can mint a wrap that
* verifies at the plane's address (CORD-02 §2), and wrapping without the secret
* throws rather than missigning. Rank and key possession can diverge — a freshly
* promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3),
* and the UI gates on rank — so every moderation verb no-ops through this check
* instead of crashing on a rank-gated action.
*/
private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? {
val cp = session.controlPlaneKeys()
if (!cp.canWrite) {
Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" }
return null
}
return cp
}
/**
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
*
* Every moderation verb below funnels through this. It used to live only in the composables that
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
*
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
* anything.
*/
private fun isAuthorizedFor(
session: ConcordCommunitySession,
bit: Int,
target: HexKey? = null,
): Boolean {
val me = account.signer.pubKey
if (session.entry.owner.equals(me, ignoreCase = true)) return true
val authority = session.state.value?.authority ?: return false
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
// banned staffer keep acting for as long as they hold the key.
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
if (!allowed) {
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
}
return allowed
}
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
private fun controlKeysForAction(
session: ConcordCommunitySession,
bit: Int,
target: HexKey? = null,
): ControlPlaneKeys? {
if (!isAuthorizedFor(session, bit, target)) return null
return controlKeysForWrite(session)
}
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
suspend fun grantConcordRole(
communityId: String,
@@ -459,7 +841,23 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
// A Grant that first makes its member staff must deliver the control_root in the same
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
// roles carry a Control-writing bit and we hold the secret to hand over.
val wrap =
ConcordModeration.grantWithStaffDelivery(
actor = account.signer,
controlPlane = cp,
communityId = communityId.hexToByteArray(),
member = member,
roleIds = roleIds,
current = session.controlEditions(),
createdAt = TimeUtils.now(),
owner = session.entry.owner,
controlRoot = session.entry.controlRoot?.hexToByteArray(),
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -494,11 +892,11 @@ class AccountConcordActions(
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == account.signer.pubKey) return null
val communityId = channel.channelId.communityId
val state =
account.concordSessions
.sessionFor(communityId)
?.state
?.value ?: return null
val session = account.concordSessions.sessionFor(communityId) ?: return null
val state = session.state.value ?: return null
// Rank alone isn't enough on a split epoch: the Grant edition takes the control_root
// (CORD-02 §2), so don't offer an action the verb would refuse.
if (!session.controlPlaneKeys().canWrite) return null
if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null
val adminRoleId =
state.roles.entries
@@ -515,7 +913,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = session.controlPlaneKey()
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val existing =
session.state.value
@@ -530,7 +928,22 @@ class AccountConcordActions(
roleId.toHexKey()
}
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
// Admin carries every management bit, so this Grant makes its member staff: it must
// deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds
// authority it cannot publish under.
val grantWrap =
ConcordModeration.grantWithStaffDelivery(
actor = account.signer,
controlPlane = cp,
communityId = communityId.hexToByteArray(),
member = member,
roleIds = listOf(roleIdHex),
current = session.controlEditions(),
createdAt = TimeUtils.now(),
owner = session.entry.owner,
controlRoot = session.entry.controlRoot?.hexToByteArray(),
epoch = session.entry.rootEpoch,
)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -542,7 +955,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, grantWrap)
return true
}
@@ -568,12 +982,11 @@ class AccountConcordActions(
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
if (author == account.signer.pubKey) return null
val communityId = channel.channelId.communityId
val authority =
account.concordSessions
.sessionFor(communityId)
?.state
?.value
?.authority ?: return null
val session = account.concordSessions.sessionFor(communityId) ?: return null
val authority = session.state.value?.authority ?: return null
// Rank alone isn't enough on a split epoch: the banlist edition takes the control_root
// (CORD-02 §2), so don't offer an action the verb would refuse.
if (!session.controlPlaneKeys().canWrite) return null
if (authority.isOwner(author)) return null
// The owner short-circuits rather than going through canActOn: canActOn starts at
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
@@ -590,7 +1003,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -602,7 +1016,8 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -632,16 +1047,32 @@ class AccountConcordActions(
val session = account.concordSessions.sessionFor(communityId) ?: return false
val state = session.state.value ?: return false
val authority = state.authority
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
// ban-aware predicate), but that is a race against banlist propagation, not a check.
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
if (!iCanBan) return false
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
if (!authority.isOwner(account.signer.pubKey) &&
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
) {
return false
}
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
val cp = controlKeysForWrite(session) ?: return false
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
// first, so each subsequent edition chains onto the updated banlist head.
for (target in removedLower) {
val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, banWrap)
}
@@ -662,22 +1093,34 @@ class AccountConcordActions(
.apply {
removeAll(removedLower)
removeAll(authority.bannedMembers())
}.toList()
}.let { candidates -> boundRecipients(candidates, authority) }
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
val entry = session.entry
val newRoot = RandomInstance.bytes(32)
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
val newControlRoot = RandomInstance.bytes(32)
// The staff set the new secret goes to: the owner plus everyone holding a
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a
// blob per recipient, so staff who aren't recipients are simply never reached.)
val staff = authority.staffMembers()
val build =
ConcordActions.buildRefounding(
rotatorSigner = account.signer,
communityId = communityId,
priorRoot = entry.root.hexToByteArray(),
newRoot = newRoot,
newControlRoot = newControlRoot,
rootEpoch = entry.rootEpoch,
priorControlWraps = session.controlPlaneWraps(),
priorControlKey = session.controlPlaneKey(),
priorControlKeys = cp,
recipientsXOnly = recipients,
staffXOnly = staff,
createdAt = TimeUtils.now(),
ownerPubKey = entry.owner,
)
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
@@ -690,10 +1133,60 @@ class AccountConcordActions(
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
adoptConcordRoot(entry, newRoot, build.newEpoch)
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them,
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
// reading it here would hand us the epoch we just left and re-mint every link onto it.
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
return true
}
/**
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
* for when there are too many.
*
* `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
*
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
* back is a recovery path that needs to know it happened.
*/
private fun boundRecipients(
candidates: Set<HexKey>,
authority: AuthorityResolver,
): List<HexKey> {
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
// the point of the cap.
val vouched = authority.roleHolders() + authority.staffMembers()
val kept = LinkedHashSet<HexKey>()
candidates.filterTo(kept) { it in vouched }
for (candidate in candidates) {
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
kept.add(candidate)
}
val dropped = candidates.size - kept.size
if (dropped > 0) {
Log.w("Concord") {
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
"stranded on the prior epoch"
}
}
return kept.toList()
}
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
// adopted — and re-published — twice on successive revision ticks.
@@ -710,31 +1203,18 @@ class AccountConcordActions(
entry: ConcordCommunityListEntry,
newRoot: ByteArray,
newEpoch: Long,
) {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
val next =
ConcordCommunityListEntry(
id = entry.id,
owner = entry.owner,
ownerSalt = entry.ownerSalt,
root = newRoot.toHexKey(),
rootEpoch = newEpoch,
heldRoots = held,
privateChannels = entry.privateChannels,
relays = entry.relays,
name = entry.name,
addedAt = entry.addedAt,
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
// out of would be unrecoverable.
inviteRef = entry.inviteRef,
excludedAtEpoch = entry.excludedAtEpoch,
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
// must survive our rotation write, or we delete their data on every rekey.
residue = entry.residue,
)
newControlPk: ByteArray? = null,
newControlRoot: ByteArray? = null,
): ConcordCommunityListEntry? {
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null
// The rewrite itself — banking the leaving epoch's address for the anti-rollback floor,
// dropping stale control material on a legacy rotation, preserving invite_ref and residue —
// is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and
// the Guestbook re-announce below are Android's.
val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot)
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next))
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
return next
}
/**
@@ -769,6 +1249,7 @@ class AccountConcordActions(
wraps = wraps,
baseRekey = session.nextBaseRekeyKey(),
recipientSigner = account.signer,
communityId = entry.id,
priorRoot = entry.root.hexToByteArray(),
rootEpoch = entry.rootEpoch,
) ?: continue
@@ -779,7 +1260,49 @@ class AccountConcordActions(
// who has themselves been banned could still rotate the whole community.
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
if (!authorized) continue
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
adopted?.let { next ->
val moved = refreshConcordInviteLinks(next)
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
}
}
}
/**
* Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the
* promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the
* granter↔member pairwise key, so promotion and key delivery are one signed edition
* with nothing separate to watch an inbox for.
*
* Adoption is gated twice and fails closed both times. The secret is adopted only if
* it derives to exactly the `control_pk` we already hold for the named epoch — a
* garbage wrap is attributable griefing, nothing worse — and only from a Grant our own
* fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts.
* The epoch check matters because compaction re-wraps a Grant head verbatim across
* Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch.
*
* Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the
* revision tick, like the rekey drain.
*/
internal suspend fun drainConcordStaffGrants() {
if (!account.isWriteable()) return
for (session in account.concordSessions.sessions()) {
val entry = session.entry
val state = session.state.value ?: continue
// The whole decision — are we staff, does a Grant carry a wrap, does it open, name our
// epoch, and derive to the control_pk we hold — is shared with `amy` in
// [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's.
val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue
account.sendMyPublicAndPrivateOutbox(
account.concordChannelList.follow(entry.withControlRoot(delivered)),
)
}
}
@@ -836,7 +1359,29 @@ class AccountConcordActions(
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
// A removed member holds the link's unlock token forever, so without this the sweep
// walks them straight back into the epoch they were rotated out of — see A2 in
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
// one whose Control Plane we can still fold.
//
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
// — so a banned member's own client would have hit that window on cold start and
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
// means no recovery; the next sweep retries once the roster is known.
val authority =
account.concordSessions
.sessionFor(entry.id)
?.state
?.value
?.authority
if (authority == null) {
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
lastConcordRecoveryCheck.remove(entry.id)
continue
}
val bannedHere = authority.isBanned(account.signer.pubKey)
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
@@ -859,8 +1404,9 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -876,9 +1422,10 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
val channelId = RandomInstance.bytes(32)
val channel = ChannelEntity(name = name.trim())
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -891,6 +1438,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
// Carry the standing definition forward and change only the name. A ChannelEntity built from
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
@@ -900,7 +1448,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -913,6 +1461,7 @@ class AccountConcordActions(
): Boolean {
val session = account.concordSessions.sessionFor(communityId) ?: return false
if (!account.isWriteable()) return false
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
// Same as rename: preserve the standing flags so a tombstone does not also silently
// reclassify the channel it retires.
val standing =
@@ -921,7 +1470,7 @@ class AccountConcordActions(
?.get(channelIdHex)
?.definition
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
publishConcordWrap(session.entry, wrap)
return true
}
@@ -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
@@ -395,7 +395,7 @@ class CachePruner(
if (noteEvent is ReportEvent) {
noteEvent.reportedAuthor().forEach {
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports ->
reports.removeReport(note)
reports.removeReportNamingUser(note)
}
@@ -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
@@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* needing the full [LocalCache] API.
*/
interface Dao {
fun getOrCreateUser(hex: HexKey): User
fun getOrCreateUser(pubkey: HexKey): User
fun getOrCreateNote(hex: HexKey): Note
@@ -480,7 +480,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
@Volatile
var lnurlEndpointResolver: LnurlEndpointResolver? = null
val relayHints = HintIndexer()
override val relayHints = HintIndexer()
/**
* Cashu mint URL directory, populated passively as
@@ -680,18 +680,18 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
override fun getOrCreateUser(hex: HexKey): User {
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
override fun getOrCreateUser(pubkey: HexKey): User {
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
// Pass `this` as the UserContext — User now resolves each pinned
// addressable note (kind:10002 / 10050 / 10019) lazily on first
// read, instead of all-or-nothing at construction time.
return users.getOrCreate(hex) { User(it, userContext) }
return users.getOrCreate(pubkey) { User(it, userContext) }
}
/** [UserContext] bridge to this cache's addressable lookup. */
@@ -1865,7 +1865,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
val new = consumeRegularEvent(event, relay, wasVerified)
if (new) {
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) }
val eventsReported =
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
@@ -1886,7 +1886,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
// and there is no threshold here to absorb that noise the way
// `receivedReportsByAuthor`'s hide path does.
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey }
authorsReported.forEach { author ->
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
}
@@ -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 =
@@ -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
@@ -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
@@ -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) }
}
/**
@@ -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
@@ -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,
@@ -373,6 +373,16 @@ class NotificationRelayService : Service() {
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))
@@ -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}" }
@@ -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)
}
}
@@ -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
}
@@ -78,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,
@@ -90,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,
@@ -77,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,
@@ -78,7 +78,7 @@ class NotifyCoordinator(
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
newEose(TimeUtils.now(), relay, forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -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)
}
}
}
}
}
}
}
@@ -193,7 +193,7 @@ class AccountNotificationsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.notificationHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -124,7 +124,7 @@ class NwcNotificationsEoseManager(
newEose(key, relay, TimeUtils.now(), forFilters)
}
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -115,7 +115,7 @@ class AccountGiftWrapsHistoryEoseManager(
// cursors so a late callback can't move another account's cursors. newEose runs regardless.
val myCursors = key.account.chatroomList.giftWrapHistory
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
class ChannelLoaderSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChannelFinderQueryState>,
) : SingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
) : AccountScopedSingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
override fun updateFilter(keys: List<ChannelFinderQueryState>): List<RelayBasedFilter> = filterMissingChannelsById(keys)
override fun distinct(key: ChannelFinderQueryState) = key.channel
@@ -21,30 +21,30 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.LifecycleAwareKeyDataSourceSubscription
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssemblerSubscription
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
/**
* Back-compat aliases: the per-note event finder moved to commons
* (`com.vitorpamplona.amethyst.commons.relayClient.event`). Existing Android call
* sites that reference these by their old names resolve here.
*/
typealias EventFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderFilterAssembler
typealias EventFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
/**
* Android convenience overload: pulls the account + shared event-finder data source
* out of [accountViewModel] and delegates to the commons subscription. `Account`
* is-a `UserFinderAccount`, so no adaptation is needed.
*/
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
accountViewModel: AccountViewModel,
) = EventFinderFilterAssemblerSubscription(note, accountViewModel.account, accountViewModel.dataSources().eventFinder)
@Composable
fun EventFinderFilterAssemblerSubscription(
note: Note,
account: Account,
dataSource: EventFinderFilterAssembler,
) {
// different screens get different states
// even if they are tracking the same tag.
val state =
remember(note, account) {
EventFinderQueryState(note, account)
}
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
}
) = EventFinderFilterAssemblerSubscription(
note,
accountViewModel.account,
accountViewModel.dataSources().eventFinder,
)
@@ -20,7 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
@@ -0,0 +1,30 @@
/*
* 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.user
/**
* Back-compat aliases: the per-user metadata finder moved to commons
* (`com.vitorpamplona.amethyst.commons.relayClient.user`). Existing Android call
* sites that reference these by their old names resolve here.
*/
typealias UserFinderFilterAssembler = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssembler
typealias UserFinderQueryState = com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingAddressables
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindAddress
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
@@ -37,7 +37,7 @@ fun filterByAddress(
val list =
mapOfSet {
if (note.event == null) {
potentialRelaysToFindAddress(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindAddress(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.address)
}
}
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.service.relayClient.searchCommand.subassemblies
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.commons.relayClient.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.filterMissingEvents
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.loaders.potentialRelaysToFindEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -38,16 +38,16 @@ fun filterByEvent(
val list =
mapOfSet {
if (note !is AddressableNote && note.event == null) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
potentialRelaysToFindEvent(LocalCache, note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
}
}
// loads threading that is event-based
note.replyTo?.forEach { parentNote ->
if (parentNote !is AddressableNote && note.event == null) {
potentialRelaysToFindEvent(note).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, note.idHex)
if (parentNote !is AddressableNote && parentNote.event == null) {
potentialRelaysToFindEvent(LocalCache, parentNote).ifEmpty { default }.forEach { relayUrl ->
add(relayUrl, parentNote.idHex)
}
}
}
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
private val clientListener =
object : RelayConnectionListener {
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList
typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime
typealias EOSEAccountFast<T> = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast<T>
open class EOSEByKey<U : Any>(
cacheSize: Int = 200,
@@ -113,60 +114,3 @@ open class EOSEAccountKey<U : Any>(
time: Long,
) = addOrUpdate(user, listCode, relayUrl, time)
}
class EOSEAccountFast<T : Any>(
cacheSize: Int = 20,
) {
private val users: LruCache<T, EOSERelayList> = LruCache(cacheSize)
private val lock = Any()
fun addOrUpdate(
user: T,
relayUrl: NormalizedRelayUrl,
time: Long,
) {
synchronized(lock) {
val relayList = users[user]
if (relayList == null) {
val newList = EOSERelayList()
users.put(user, newList)
newList.addOrUpdate(relayUrl, time)
} else {
relayList.addOrUpdate(relayUrl, time)
}
}
}
fun removeEveryoneBut(list: Set<T>) {
synchronized(lock) {
users.snapshot().forEach {
if (it.key !in list) {
users.remove(it.key)
}
}
}
}
fun removeDataFor(user: T) {
synchronized(lock) {
users.remove(user)
}
}
fun since(key: T): SincePerRelayMap? =
synchronized(lock) {
users[key]?.relayList?.toMutableMap()
}
fun sinceRelaySet(key: T): Set<NormalizedRelayUrl>? =
synchronized(lock) {
users[key]?.relayList?.keys?.toSet()
}
fun newEose(
user: T,
relayUrl: NormalizedRelayUrl,
time: Long,
) = addOrUpdate(user, relayUrl, time)
}
@@ -20,10 +20,22 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import android.os.SystemClock
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.Log
import java.util.concurrent.ConcurrentHashMap
/**
* Counts relay websocket traffic into the usage ledger. Frame sizes are
@@ -31,29 +43,210 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
* (relay JSON is ASCII-dominant), consistent with how RelayStats counts.
* Excludes WS framing/compression; good enough for "which subsystem is
* eating my data plan" comparisons.
*
* Beyond the byte totals this also carries the relay-churn diagnostics: a
* per-verb split of those same bytes, a connection-lifetime histogram, and
* per-relay failure/short-session counts. See
* plans/2026-07-29-relay-churn-diagnostics.md for what each answers and how to
* read them together.
*
* The verb split takes its name straight from the wire label the command already
* knows (`Command.label()` / `Message.label()`), so `Σ verb == Σ msg` holds by
* construction and a new subtype needs no change here.
*/
class RelayUsageListener(
private val accountant: ResourceUsageAccountant,
private val isMobile: () -> Boolean,
private val isForeground: () -> Boolean,
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
) : RelayConnectionListener {
/**
* Relay -> when its current session became ready. Touched from the per-relay
* OkHttp dispatcher threads, hence concurrent. Keyed by [NormalizedRelayUrl] to
* match the other per-relay caches (`RelayStats`, `RelayLimitsTracker`).
*
* Entries are consumed on disconnect. Three ways a session escapes the map
* unrecorded, all counted rather than prevented — see [UsageKeys.RELAY_LIFE_OVERWRITE],
* [UsageKeys.RELAY_LIFE_ORPHAN], and [UsageKeys.relayConnects] for process death.
*/
private val connectedSince = ConcurrentHashMap<NormalizedRelayUrl, Long>()
/**
* Relay -> subscription ids currently open on this connection, so a REQ that
* replaces an in-flight subscription can be told apart from one that opens a new
* one. Cleared on disconnect, because the relay forgets them too — every REQ
* after a reconnect is legitimately new.
*
* Bounded by the live subscription count per relay (tens), not by session length.
*/
private val openSubs = ConcurrentHashMap<NormalizedRelayUrl, MutableSet<String>>()
/**
* Subscription id -> the purpose that opened it, for attributing inbound frames:
* an EVENT names only its subscription, never why the client asked for it.
*
* Deliberately **not** [openSubs]. Sharing one map conflated two different
* lifetimes and lost 41 % of the download to `unattributed` in the 2026-08-02
* reading: a CLOSE removed the id, and a disconnect dropped the whole relay's
* map, while frames already in flight were still arriving. "Is this subscription
* open" and "what did this subscription belong to" answer different questions and
* expire at different times — the second stays true after the first turns false.
*
* Keyed by subscription id alone, without the relay. The same id is used across
* relays for one logical subscription, so the purpose is a property of the id;
* this also means a frame arriving after a reconnect still attributes.
*
* Bounded by [MAX_TRACKED_SUBS] with wholesale eviction rather than an LRU: this
* is a diagnostic on a hot path, ids are recycled steadily, and a rare reset that
* sends a few frames to `unattributed` is cheaper than per-frame bookkeeping.
* `unattributed` staying small is what says the bound is generous enough.
*/
private val subPurpose = ConcurrentHashMap<String, String>()
/**
* Event ids delivered recently, as the first 64 bits of the id.
*
* Held as a Long rather than the 64-char hex, which saves the id strings
* themselves — but a boxed `Long` plus its map node still costs ~56 bytes an
* entry, so a full window is ~3 MB, not the few hundred KB the raw payload
* suggests. Worth knowing before raising [MAX_TRACKED_EVENTS] on a 512 MB-class
* device; an unboxed open-addressed `LongArray` would be ~400 KB if it ever needs
* to grow. 64 bits makes a collision between distinct ids negligible where a
* 32-bit hash would not be.
*
* The window only needs to span the fan-out, not the session: the same event
* arrives from every relay carrying it within seconds, so near-term memory
* catches the duplication this measures. Cleared wholesale at [MAX_TRACKED_EVENTS]
* for the same reason [subPurpose] is — the alternative is per-frame LRU
* bookkeeping on the hottest path in the app. A clear undercounts duplicates that
* straddle it, so the ratio is a floor.
*/
private val recentEventIds = ConcurrentHashMap.newKeySet<Long>()
/**
* Relay -> when this dial was decided, so the pre-request cost can be separated
* from the handshake. Consumed by whichever of `onConnected`/`onCannotConnect`
* ends the dial, so an entry never outlives the attempt that made it.
*/
private val dialStartedAt = ConcurrentHashMap<NormalizedRelayUrl, Long>()
/** Notice texts already logged, so one wording costs one line however often it arrives. */
private val loggedNotices = ConcurrentHashMap.newKeySet<String>()
override fun onSent(
relay: IRelayClient,
cmdStr: String,
cmd: Command,
success: Boolean,
) {
if (success) {
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong())
if (!success) return
val bytes = cmdStr.length.toLong()
val mobile = isMobile()
val fg = isForeground()
accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes)
accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes)
when (cmd) {
is ReqCmd -> {
accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1)
val purpose = purposeOf(cmd)
accountant.add(UsageKeys.relayPurposeSent(purpose), 1)
accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes)
if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear()
subPurpose[cmd.subId] = purpose
// Already open on this connection, so this REQ replaces a live
// subscription rather than starting one.
// computeIfAbsent, not getOrPut: the latter is get-then-put and two
// threads racing the first REQ after a (re)connect would each build a
// set, the losing put's subId vanishing with its orphaned set.
// `sendIfConnected` deliberately calls listeners outside PoolRequests'
// stripe lock, so same-relay concurrency here is by design.
val known = openSubs.computeIfAbsent(relay.url) { ConcurrentHashMap.newKeySet() }
if (!known.add(cmd.subId)) {
accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1)
}
// Within the window after this relay's connect, so almost certainly
// part of syncState's replay rather than a user action. A time
// window because nothing on this side marks a frame as belonging to
// it; see UsageKeys.relaySubsReplay.
val since = connectedSince[relay.url]
if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) {
accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1)
}
}
is CloseCmd -> {
accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1)
openSubs[relay.url]?.remove(cmd.subId)
}
}
}
override fun onIncomingMessage(
override suspend fun onIncomingMessage(
relay: IRelayClient,
msgStr: String,
msg: Message,
) {
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong())
val bytes = msgStr.length.toLong()
val mobile = isMobile()
val fg = isForeground()
accountant.add(UsageKeys.relayMsg(mobile, fg, received = true), bytes)
accountant.add(UsageKeys.relayVerb(msg.label(), received = true, mobile, fg), bytes)
// Attribute the inbound side to whoever asked for it. Only frames that name a
// subscription can be attributed; NOTICE and OK are relay-wide and are left out
// rather than guessed at, which is why this does not reconcile to msg.rx.
// Resolved once: the duplicate check below needs the same answer, and an
// EVENT names only its subscription, never why the client asked for it.
val purpose = subIdOf(msg)?.let { subPurpose[it] ?: UsageKeys.PURPOSE_UNATTRIBUTED }
if (purpose != null) {
accountant.add(UsageKeys.relayPurposeDown(purpose), bytes)
accountant.add(UsageKeys.relayPurposeDownCount(purpose), 1)
}
if (msg is EventMessage) {
accountant.add(UsageKeys.relayEventsSeen(mobile, fg), 1)
idPrefix(msg.event.id)?.let { key ->
if (recentEventIds.size >= MAX_TRACKED_EVENTS) recentEventIds.clear()
if (!recentEventIds.add(key)) {
accountant.add(UsageKeys.relayEventsDup(mobile, fg), 1)
accountant.add(UsageKeys.relayEventsDupBytes(mobile, fg), bytes)
if (purpose != null) accountant.add(UsageKeys.relayPurposeDupBytes(purpose), bytes)
}
}
}
// A refused subscription arrives here and nowhere else: the NOTICE carries no
// subscription id, so RelayReqRefusals (wired to CLOSED) never sees it.
if (msg is NoticeMessage) {
val reason = UsageKeys.noticeReason(msg.message)
accountant.add(UsageKeys.relayNotice(reason), 1)
if (reason == UsageKeys.NOTICE_UNCLASSIFIED) {
// The counter alone cannot say whether an absent `toomanysubs` means no
// refusals or an allowlist that misses how this relay words them.
//
// INFO, not DEBUG: a debug build defaults to LogLevel.INFO
// (Amethyst.DEFAULT_LOG_LEVEL, with VERBOSE_LOGS off), so a DEBUG line
// here is dropped before it reaches the sink and this said nothing at
// all. Demote it once the allowlist stops needing evidence.
//
// One line per distinct wording rather than per frame: the ledger
// already has the count, what is missing is the variety. Truncated and
// capped because the text is server-controlled.
if (loggedNotices.size < MAX_DISTINCT_NOTICES && loggedNotices.add(msg.message)) {
Log.i(TAG) { "Unclassified NOTICE from ${relay.url.url}: ${msg.message.take(MAX_NOTICE_LOG)}" }
}
}
}
}
/** Dial attempts. Unlike [onCannotConnect] this really is one per dial. */
override fun onConnecting(relay: IRelayClient) {
accountant.add(UsageKeys.relayDials(isMobile(), isForeground()), 1)
dialStartedAt[relay.url] = nowMs()
}
// Every completed (re)connection paid a TCP+TLS handshake; high daily
@@ -64,13 +257,109 @@ class RelayUsageListener(
pingMillis: Int,
compressed: Boolean,
) {
accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1)
val mobile = isMobile()
val fg = isForeground()
// Doubles as the lifetime histogram's denominator — one session begins here.
accountant.add(UsageKeys.relayConnects(mobile, fg), 1)
// Consumed unconditionally: the gap needs a handshake to subtract, but the
// stamp has to go either way or a dial that cannot be timed leaks its entry.
val dialedAt = dialStartedAt.remove(relay.url)
// pingMillis is the transport's own handshake timing; <= 0 means it could
// not measure it, and a fabricated 0 would be worse than no record.
if (pingMillis > 0) {
accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1)
if (dialedAt != null) {
val gap = nowMs() - dialedAt - pingMillis
if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1)
}
}
if (connectedSince.put(relay.url, nowMs()) != null) {
accountant.add(UsageKeys.RELAY_LIFE_OVERWRITE, 1)
}
}
override fun onDisconnected(relay: IRelayClient) {
val mobile = isMobile()
val fg = isForeground()
accountant.add(UsageKeys.relayDisconnects(mobile, fg), 1)
openSubs.remove(relay.url)
val startedAt = connectedSince.remove(relay.url)
if (startedAt == null) {
// A dial that never became ready, or a second disconnect for one session.
accountant.add(UsageKeys.RELAY_LIFE_ORPHAN, 1)
return
}
val elapsed = (nowMs() - startedAt).coerceAtLeast(0)
accountant.add(UsageKeys.relayLife(elapsed, mobile, fg), 1)
}
/**
* The [SubPurpose] behind a REQ, from the first filter that declares one.
*
* One subscription id carries one purpose in practice, so the first is the
* subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s
* predates #3832's tagging and is counted separately rather than guessed at.
*/
private fun purposeOf(cmd: ReqCmd): String =
cmd.filters
.firstNotNullOfOrNull { it.purposeOrNull() }
?.let { UsageKeys.purposeKeyPart(it) }
?: UsageKeys.PURPOSE_UNEXPLAINED
/**
* The first 64 bits of an event id, or null if it is not a well-formed id.
*
* Parsed in place rather than `substring(0, 16).toULongOrNull(16)`: this runs on
* every inbound EVENT frame, and the substring would be a String plus its backing
* array per event, thrown away immediately.
*/
private fun idPrefix(id: String): Long? {
if (id.length < 16) return null
var acc = 0L
for (i in 0 until 16) {
val digit = Character.digit(id[i], 16)
if (digit < 0) return null
acc = (acc shl 4) or digit.toLong()
}
return acc
}
/** The subscription a frame belongs to, when it names one. */
private fun subIdOf(msg: Message): String? =
when (msg) {
is EventMessage -> msg.subId
is EoseMessage -> msg.subId
is ClosedMessage -> msg.subId
is CountMessage -> msg.queryId
else -> null
}
override fun onCannotConnect(
relay: IRelayClient,
errorMessage: String,
) {
accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1)
// A dial that ends here never reaches onConnected, so nothing else would
// ever consume its start stamp.
dialStartedAt.remove(relay.url)
}
companion object {
private const val TAG = "RelayUsage"
/** NOTICE text is server-controlled; cap what reaches the log. */
private const val MAX_NOTICE_LOG = 200
/** Ceiling on distinct wordings held in memory; relay prose is unbounded. */
private const val MAX_DISTINCT_NOTICES = 200
/** Ceiling on remembered subscription-id purposes. See [subPurpose]. */
private const val MAX_TRACKED_SUBS = 4_000
/** Recent-event-id window. See [recentEventIds]. */
private const val MAX_TRACKED_EVENTS = 50_000
}
}
@@ -38,7 +38,9 @@ import java.util.concurrent.atomic.AtomicLong
* hands off the accumulated value atomically, whereas remove+sum on a
* LongAdder can strand a racing increment on an orphaned cell. Entries stay
* in the map after a drain — the key space is small and fixed (dims x areas),
* so this costs a few hundred boxed zeros at most.
* so this costs a few hundred boxed zeros at most. The churn counters roughly
* tripled it, but every one of them is still compile-time bounded: no counter
* is keyed on a relay url, a host, or any other runtime string.
*
* Counters added from inside a pre-flush hook (the CPU sampler, the segment
* integrators closing an open segment) never re-arm the debounce: they are
@@ -74,8 +76,16 @@ class ResourceUsageAccountant(
amount: Long,
) {
if (amount <= 0) return
live.computeIfAbsent(key) { AtomicLong() }.addAndGet(amount)
// Plain get first: computeIfAbsent locks the bin head when the key is present
// but not the head node, and the churn counters roughly tripled the key count
// (so collisions) on a path that runs per relay frame.
(live[key] ?: live.computeIfAbsent(key) { AtomicLong() }).addAndGet(amount)
if (inHookRun.get() == true) return
// Plain read before the CAS: in steady state a flush is always already armed,
// and the churn counters made this 5-8 calls per relay frame — every one of
// which would otherwise be a read-modify-write on the same shared cache line
// from every relay's socket thread, only to fail.
if (flushScheduled.get()) return
if (flushScheduled.compareAndSet(false, true)) {
scope.launch {
delay(flushDebounceMs)
@@ -75,16 +75,49 @@ class ResourceUsageReportAssembler {
sb.append("\nTechnical details (per epoch-day):\n")
sb.append("```\n")
days.toSortedMap().forEach { (day, counters) ->
val sorted = days.toSortedMap()
val included = newestDaysWithin(sorted, MAX_DUMP_CHARS)
sorted.forEach { (day, counters) ->
if (day !in included) return@forEach
sb.append("day $day (today=$today)\n")
counters.toSortedMap().forEach { (key, value) ->
sb.append(" $key = $value\n")
}
}
val omitted = sorted.size - included.size
if (omitted > 0) {
sb.append("($omitted earlier day(s) omitted to keep this report sendable; ")
sb.append("the summary tables above still cover them)\n")
}
sb.append("```\n")
return sb.toString()
}
/**
* The most recent days whose dumps fit in [budget], newest first, always
* including at least the newest even if it alone exceeds it.
*
* Bounded by size rather than by a day count because the per-day size is not a
* constant: it tracks how many distinct counters the build emits, and that has
* grown by more than an order of magnitude. A fixed day count would have to be
* re-tuned every time a counter family is added, and would be wrong in the
* meantime.
*/
private fun newestDaysWithin(
days: Map<Long, Map<String, Long>>,
budget: Int,
): Set<Long> {
val included = mutableSetOf<Long>()
var left = budget
for (day in days.keys.sortedDescending()) {
val size = days.getValue(day).entries.sumOf { it.key.length + DUMP_LINE_OVERHEAD }
if (included.isNotEmpty() && size > left) break
included.add(day)
left -= size
}
return included
}
private fun summaryTable(s: UsageSummary): String =
buildString {
append("| Metric | Value |\n")
@@ -132,6 +165,25 @@ class ResourceUsageReportAssembler {
/** Markdown table header/body separator row. */
private const val TABLE_SEPARATOR = "| --- | --- |\n"
/**
* Character budget for the raw per-day dump.
*
* This report exists to be sent to the developers as a NIP-17 DM, and relays
* commonly cap events between 64 and 256 KB — so an unbounded dump does not
* merely inconvenience, it makes the report unsendable by exactly the users
* whose ledgers are most worth seeing. It also travels through a ~1 MB Binder
* transaction when shared.
*
* The ledger keeps 30 days and a busy day now emits ~1,200 counters, which is
* ~52 KB of dump per day — so "every retained day" would be ~1.5 MB. This
* keeps the newest days and says how many it dropped; the summary tables
* above are unaffected and still cover the whole window.
*/
private const val MAX_DUMP_CHARS = 64 * 1024
/** ` ` + ` = ` + the value, per dumped line. */
private const val DUMP_LINE_OVERHEAD = 24
fun formatBytes(bytes: Long): String =
when {
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
@@ -34,13 +34,31 @@ import java.io.File
* Jackson + Mutex + write-to-tmp-then-rename + version envelope.
*
* Day keys are UTC epoch-days (stringified for JSON). Buckets older than
* [keepDays] are pruned on every merge, so the file stays small (a few KB).
* [keepDays] are pruned on every merge, so the file stays small (a few KB, on a
* key space the churn counters tripled but left compile-time bounded). The whole
* file is re-serialized on every flush (debounced to ~30s while traffic flows),
* so that size is paid on each write, not just at rest.
* Also carries the high-consumption alert state (last prompt time, opt-out)
* so the whole feature has exactly one file.
*/
class ResourceUsageStore(
private val storageFile: File,
private val keepDays: Long = 30,
/**
* Retention, in days.
*
* Seven because that is the widest window anything reads: the summary tables and
* the trend chart both span `today - 6 .. today`, the alert evaluator looks at
* two days, and the report's raw dump is byte-bounded well below a week. At 30 —
* the previous value — twenty-three days were rewritten on every flush and read
* by nothing.
*
* That is not free: [persist] rewrites the whole file on every merge, and the
* relay-churn counters took a day's bucket from ~57 keys to ~241. Retention is
* therefore a write-amplification setting as much as a history setting — cutting
* it to a week is what keeps those counters at ~18% over the previous file size
* rather than ~5x.
*/
private val keepDays: Long = 7,
) {
data class UsageFile(
val version: Int = 1,
@@ -20,6 +20,18 @@
*/
package com.vitorpamplona.amethyst.service.resourceusage
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.AUTH_REQUIRED
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.BLOCKED
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.ERROR
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.INVALID
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RATE_LIMITED
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RESTRICTED
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.UNSUPPORTED
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver
import java.util.concurrent.ConcurrentHashMap
/**
* Counter-key grammar for the resource-usage ledger. Keys are flat strings so
* the on-disk store is schema-free — adding a counter never needs a migration.
@@ -29,8 +41,28 @@ package com.vitorpamplona.amethyst.service.resourceusage
* - visibility: `fg` (an activity is started) vs `bg`
* - direction: `rx` (downloaded) vs `tx` (uploaded)
*
* Counters are sizes, durations, and counts only — never URLs, relay names, or
* content. See plans/2026-07-12-resource-usage-ledger.md.
* Counters are sizes, durations, and counts only — never content, and never a
* full URL, and never a relay name.
* See plans/2026-07-12-resource-usage-ledger.md.
*
* ## Reserved segments — read before adding a counter
*
* [sumMatching] matches by dot-segment *membership*, not by prefix, and every
* headline figure in [UsageSummary] is built from it. A new key that happens to
* contain one of these segments silently joins that sum:
*
* rx tx msg connms connects connfails reqs bursts activems
* worker runs + every value in [HTTP_ROLES]
*
* Concretely: a key named `relay.rx.event.mobile.bg` would be counted by
* `traffic(MOBILE, BG)` *in addition to* `relay.msg.mobile.bg.rx`, doubling the
* reported data usage and halving the effective threshold of the
* background-mobile-data alert. That is why the relay verb split below uses
* `up`/`down` rather than `tx`/`rx`.
*
* `ResourceUsageLedgerTest.newKeysDoNotDisturbSummary` is the regression guard:
* it asserts [UsageSummary.from] is value-identical with and without every key
* this object can produce.
*/
object UsageKeys {
const val MOBILE = "mobile"
@@ -54,6 +86,42 @@ object UsageKeys {
val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER)
/**
* `mobile.bg` — the network x visibility pair every counter is split by.
*
* Table-backed rather than interpolated: this is evaluated on every relay frame
* and every HTTP response, and there are only four possible answers. Declared
* first because the key tables below are built from it at class-init.
*/
fun dim(
mobile: Boolean,
foreground: Boolean,
): String = DIMS[dimIndex(mobile, foreground)]
private val DIMS = arrayOf("$WIFI.$BG", "$WIFI.$FG", "$MOBILE.$BG", "$MOBILE.$FG")
private fun dimIndex(
mobile: Boolean,
foreground: Boolean,
): Int = (if (mobile) 2 else 0) or (if (foreground) 1 else 0)
/**
* The four `<prefix>.<dim>[.<suffix>]` keys, indexed by [dimIndex].
*
* Used for every `relay.*` key, because all of them are built from a relay
* callback — per frame for [relayMsg]/[relayVerb], per dial/connect/disconnect
* for the rest. The `net.*` builders below still interpolate: their key space is
* role x dim x metric and they are called once per HTTP response, so the table
* would be larger and buy less. If you add a `relay.*` counter, table it.
*/
private fun dimKeys(
prefix: String,
suffix: String? = null,
): Array<String> {
val tail = if (suffix == null) "" else ".$suffix"
return Array(DIMS.size) { "$prefix.${DIMS[it]}$tail" }
}
/** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */
fun net(
role: String,
@@ -87,25 +155,556 @@ object UsageKeys {
mobile: Boolean,
foreground: Boolean,
received: Boolean,
): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}"
): String = (if (received) RELAY_MSG_RX else RELAY_MSG_TX)[dimIndex(mobile, foreground)]
private val RELAY_MSG_RX = dimKeys("relay.msg", RX)
private val RELAY_MSG_TX = dimKeys("relay.msg", TX)
/** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */
fun relayConnMs(
mobile: Boolean,
foreground: Boolean,
): String = "relay.connms.${dim(mobile, foreground)}"
): String = RELAY_CONNMS[dimIndex(mobile, foreground)]
/** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */
private val RELAY_CONNMS = dimKeys("relay.connms")
/**
* `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a
* TCP+TLS handshake.
*
* Also the [relayLife] histogram's denominator — one session begins per
* `onConnected` — which is what makes the histogram's deficit measurable rather
* than assumed:
*
* connects − Σ life buckets − orphan = still open at report time + lost to process death
*
* Without that subtraction a leak, a still-open session and a session lost to a
* background kill are indistinguishable.
*/
fun relayConnects(
mobile: Boolean,
foreground: Boolean,
): String = "relay.connects.${dim(mobile, foreground)}"
): String = RELAY_CONNECTS[dimIndex(mobile, foreground)]
/** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */
private val RELAY_CONNECTS = dimKeys("relay.connects")
/**
* `relay.connfails.mobile.bg` — every `onCannotConnect`.
*
* NOT a failed-dial count, despite the name. `BasicRelayClient.onFailure`
* raises `onCannotConnect` with no `isReady` test, so a connection that lived
* for ten minutes and then dropped increments both this and [relayConnects]
* from a single dial. Use [relayDials] for the actual number of dials.
*/
fun relayConnectFails(
mobile: Boolean,
foreground: Boolean,
): String = "relay.connfails.${dim(mobile, foreground)}"
): String = RELAY_CONNFAILS[dimIndex(mobile, foreground)]
private val RELAY_CONNFAILS = dimKeys("relay.connfails")
/**
* `relay.dials.mobile.bg` — dial attempts, from `onConnecting`.
*
* Fires exactly once per dial, after the transport gate and the connect mutex
* and before the socket is built, so this is the honest denominator that
* [relayConnectFails] is not.
*/
fun relayDials(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_DIALS[dimIndex(mobile, foreground)]
private val RELAY_DIALS = dimKeys("relay.dials")
/** `relay.disc.mobile.bg` — every `onDisconnected`, whatever the cause. */
fun relayDisconnects(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_DISC[dimIndex(mobile, foreground)]
private val RELAY_DISC = dimKeys("relay.disc")
/**
* `relay.subs.sent.mobile.bg` — REQ commands sent.
*
* A count to sit beside the `relay.verb.up.req` byte total: PR #3832 raised the
* number of live subscriptions per relay (background accounts now subscribe too)
* and measured refusals against nos.lol's cap of 20. Divided by [relayConnects]
* this is REQs per connection, which is what separates "we reconnect too often"
* from "each reconnect asks for too much" — different fixes.
*/
fun relaySubsSent(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_SUBS_SENT[dimIndex(mobile, foreground)]
private val RELAY_SUBS_SENT = dimKeys("relay.subs.sent")
/** `relay.subs.closed.mobile.bg` — CLOSE commands sent; sent minus closed is net subscription growth. */
fun relaySubsClosed(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_SUBS_CLOSED[dimIndex(mobile, foreground)]
private val RELAY_SUBS_CLOSED = dimKeys("relay.subs.closed")
/**
* `relay.subs.replay.mobile.bg` — REQs sent within [REPLAY_WINDOW_MS] of that
* relay's connect, i.e. the post-connect resubscribe burst.
*
* An estimate, not an exact split. `PoolRequests.syncState` replays every desired
* filter from a coroutine launched at `onConnected`, but nothing on the listener
* side marks a frame as belonging to it, so this is a time window. It is the
* measurement behind the source report's inference that ~24 KB per connection
* "is exactly the size of a full REQ subscription replay" — which was arithmetic
* on a daily total, not an observation.
*/
fun relaySubsReplay(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_SUBS_REPLAY[dimIndex(mobile, foreground)]
private val RELAY_SUBS_REPLAY = dimKeys("relay.subs.replay")
/** How long after a connect a REQ still counts as part of the resubscribe burst. */
const val REPLAY_WINDOW_MS = 2_000L
/**
* `relay.notice.toomanysubs` — NOTICE frames by reason.
*
* Exists because a refused subscription is otherwise invisible. Per PR #3832's
* own known issue, `ERROR: too many concurrent REQs` arrives as a NOTICE, which
* carries no subscription id and so never reaches `RelayReqRefusals.onRefused`
* (wired to CLOSED only). The relay drops the REQ while the client still believes
* it is live — it never EOSEs, its `since` never advances, and `syncState` then
* re-requests its full backlog on every reconnect, forever. A non-trivial count
* here means subscription pressure is a *cause* of the download volume rather
* than a symptom of the reconnect count.
*
* The reason comes from a fixed allowlist, never from the relay's text. Relay
* prose is server-controlled and this key is persisted for 30 days; `RelayObserver`
* had to fix exactly this bug, where free-form CLOSED prose became its own tally
* key and cardinality grew with the number of distinct sentences relays wrote.
*/
fun relayNotice(reason: String): String = RELAY_NOTICE[reason] ?: RELAY_NOTICE.getValue(NOTICE_UNCLASSIFIED)
const val NOTICE_TOO_MANY_SUBS = "toomanysubs"
const val NOTICE_RATE_LIMITED = "ratelimited"
const val NOTICE_AUTH_REQUIRED = "authrequired"
const val NOTICE_RESTRICTED = "restricted"
const val NOTICE_INVALID = "invalid"
const val NOTICE_BLOCKED = "blocked"
const val NOTICE_ERROR = "error"
const val NOTICE_UNSUPPORTED = "unsupported"
/** The query itself was too expensive — too many kinds/steps/filters. Observed on nostr.land, relay.layer.systems. */
const val NOTICE_QUERY_COST = "querycost"
/** The relay refuses REQs outright. Observed on sendit.nosflare.com. */
const val NOTICE_REQ_REFUSED = "reqrefused"
/** Relay chatter that costs bytes but means nothing — keepalives, per-query PERF telemetry. */
const val NOTICE_BENIGN = "benign"
/** Deliberately not `other`: that is an [HTTP_ROLES] value and a reserved segment. */
const val NOTICE_UNCLASSIFIED = "unclassified"
val NOTICE_REASONS =
listOf(
NOTICE_TOO_MANY_SUBS,
NOTICE_RATE_LIMITED,
NOTICE_AUTH_REQUIRED,
NOTICE_RESTRICTED,
NOTICE_INVALID,
NOTICE_BLOCKED,
NOTICE_ERROR,
NOTICE_UNSUPPORTED,
NOTICE_QUERY_COST,
NOTICE_REQ_REFUSED,
NOTICE_BENIGN,
NOTICE_UNCLASSIFIED,
)
private val RELAY_NOTICE = NOTICE_REASONS.associateWith { "relay.notice.$it" }
/**
* Classifies a NOTICE into one of [NOTICE_REASONS].
*
* Matches on content markers rather than the NIP-01 machine-readable prefix
* alone, because the case this exists for does not have a useful one: strfry
* sends `ERROR: too many concurrent REQs`, whose prefix is just `error`.
* [RelayObserver.prefixOf] is consulted for the standard prefixes it does
* handle correctly.
*/
fun noticeReason(message: String): String {
val text = message.lowercase()
// Several relays prefix a NOTICE with the subscription id it concerns
// ("Kgo0HH: closed: too many steps"), which makes the *subscription id* the
// machine-readable prefix and hides the real one. Try the remainder too.
val prefix = RelayObserver.prefixOf(message)
val inner = RelayObserver.prefixOf(message.substringAfter(':', ""))
val prefixes = setOf(prefix, inner)
return when {
"too many" in text && ("req" in text || "subscription" in text || "concurrent" in text) -> NOTICE_TOO_MANY_SUBS
// A cost refusal is still a refusal: the REQ is dropped, so it never
// EOSEs and its `since` never advances.
"too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST
"does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED
"keepalive" in text || "perf:" in text -> NOTICE_BENIGN
// Wire codes come from the enum rather than being hand-written a third
// time (after the enum itself and the NOTICE_* key segments).
RATE_LIMITED.code in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED
AUTH_REQUIRED.code in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED
RESTRICTED.code in prefixes -> NOTICE_RESTRICTED
INVALID.code in prefixes -> NOTICE_INVALID
BLOCKED.code in prefixes -> NOTICE_BLOCKED
UNSUPPORTED.code in prefixes -> NOTICE_UNSUPPORTED
ERROR.code in prefixes -> NOTICE_ERROR
else -> NOTICE_UNCLASSIFIED
}
}
/**
* The bar that decides reconnect behaviour, and so also what counts as a short
* session: below it a disconnect keeps the growing backoff,
* at or above it the backoff resets to 1s. (`NostrClient.KEEP_ALIVE_INTERVAL_MS`
* is the same 60s, but it is private, so this reads the one that is public.)
*
* Derived rather than copied: the plan retunes `STABLE_CONNECTION_IN_SECS` once
* the histogram is read, and a hand-written 60_000 here would silently stop
* meaning "session that kept the backoff growing" at that point.
* `UsageKeyHelpersTest.lifeBucketsAreHalfOpen` asserts the resulting bucket
* labels, so a retune surfaces as a test failure rather than as a histogram that
* quietly answers the wrong question.
*/
const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L
/**
* A half-open millisecond histogram: ascending upper [bounds], the derived
* bucket labels, and the `<prefix>.<bucket>.<dim>` key table they index.
*
* Shared by all three histograms below (`relay.life`, `relay.hs`, `relay.gap`),
* which differ only in their bounds and in whether the label reads in seconds or
* milliseconds. Deriving the names from the bounds is what keeps a bound change
* from leaving a label lying about it.
*/
private class MsHistogram(
prefix: String,
private val bounds: LongArray,
label: (Long) -> String,
) {
init {
// [indexOf] linear-scans for the first bound greater than the elapsed
// time, so the bounds must ascend. One of relay.life's is derived from
// BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five
// minutes — exactly the retune commit 2 of the churn plan contemplates —
// would push it past the two bounds after it. The buckets between would
// become unreachable and the labels would start lying. Fail at class-init
// with the reason rather than as a puzzling boundary-test failure.
require(bounds.asList() == bounds.sorted()) {
"$prefix bounds must ascend, got ${bounds.toList()}. " +
"SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match."
}
}
val names = Array(bounds.size + 1) { i -> if (i < bounds.size) "lt${label(bounds[i])}" else "gte${label(bounds.last())}" }
private val keys = Array(names.size) { dimKeys("$prefix.${names[it]}") }
fun indexOf(ms: Long): Int {
for (i in bounds.indices) {
if (ms < bounds[i]) return i
}
return bounds.size
}
fun nameOf(ms: Long): String = names[indexOf(ms)]
fun key(
ms: Long,
mobile: Boolean,
foreground: Boolean,
): String = keys[indexOf(ms)][dimIndex(mobile, foreground)]
}
/**
* Bounds for the [relayLife] histogram deliberately straddle [SHORT_SESSION_MS]:
* a mean cannot tell a tight cluster sitting on that bar from a bimodal mix;
* this can.
*/
private val LIFE = MsHistogram("relay.life", longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000)) { "${it / 1000}s" }
fun lifeBucket(elapsedMs: Long): String = LIFE.nameOf(elapsedMs)
/**
* `relay.life.lt60s.mobile.bg` — connections that closed after living this long.
* [relayConnects] is the denominator; see its doc for the deficit equation.
*/
fun relayLife(
elapsedMs: Long,
mobile: Boolean,
foreground: Boolean,
): String = LIFE.key(elapsedMs, mobile, foreground)
/**
* `relay.life.overwrite` — a connect arrived for a relay that already had an
* unconsumed start stamp.
*
* Expected during a pool teardown: `NostrClient` runs `disconnect()` then
* `connect()` synchronously, so a stale failure callback for the old socket
* can land after the new socket is already open. The new stamp overwrites the
* old, and the stale disconnect then consumes the new one — booking a
* near-zero lifetime for a session that never ended. Bias runs toward `lt5s`,
* so read this before reading the histogram's short buckets.
*/
const val RELAY_LIFE_OVERWRITE = "relay.life.overwrite"
/** `relay.life.orphan` — a disconnect with no matching start stamp. */
const val RELAY_LIFE_ORPHAN = "relay.life.orphan"
/**
* `relay.verb.up.req.mobile.bg` / `relay.verb.down.eose.mobile.bg` — the
* [relayMsg] bytes, split by wire verb. Parameterised on direction for the same
* reason [relayMsg] is: one memo strategy, not two.
*
* `verb` is the command's own `label()` (`REQ`, `EVENT`, ...) lowercased, so a
* new `Command`/`Message` subtype maps itself and nothing can land in a
* catch-all bucket unnoticed. Deliberately `up`/`down` rather than `tx`/`rx` —
* see the reserved-segment note above.
*
* Keys are memoized because this is on the per-frame path: the verb x dim space
* is a handful of entries, so steady state is a map lookup and an array index
* with no string building at all.
*/
fun relayVerb(
verb: String,
received: Boolean,
mobile: Boolean,
foreground: Boolean,
): String =
(if (received) VERB_DOWN_KEYS else VERB_UP_KEYS)
.getOrPut(verb) { dimKeys("relay.verb.${if (received) DOWN else UP}.${verb.lowercase()}") }[dimIndex(mobile, foreground)]
private const val UP = "up"
private const val DOWN = "down"
private val VERB_UP_KEYS = ConcurrentHashMap<String, Array<String>>()
private val VERB_DOWN_KEYS = ConcurrentHashMap<String, Array<String>>()
/**
* `relay.purpose.home_feed.sent` / `.bytes` — REQ frames and REQ bytes by the
* [SubPurpose] that asked for them.
*
* The counter that turns "REQ traffic is 64 % of upload" into an actionable
* name. Purpose travels on the filter itself (PR #3832's `ExplainedFilter`,
* which survives the `copy(since = …)` assemblers do after every EOSE), so this
* is a read, not a new registry.
*
* Cardinality is the enum, so it is bounded and stable. [PURPOSE_UNEXPLAINED] is
* its own bucket rather than folded into the enum's OTHER: a filter carrying no
* purpose at all means an assembler #3832 did not reach, which is a different
* fact from one that declared itself uncategorised — and if that bucket is large,
* the attribution below cannot be trusted.
*
* Memoized for the same reason [relayVerb] is, and more urgently: [relayPurposeDown]
* and [relayPurposeDownCount] both run on every inbound frame that names a
* subscription, so interpolating would build two strings per frame on the hottest
* path in the app. The purpose space is the enum plus the three fallbacks below.
*/
fun relayPurposeSent(purpose: String): String = purposeKeys(purpose)[P_SENT]
fun relayPurposeBytes(purpose: String): String = purposeKeys(purpose)[P_BYTES]
/**
* `relay.purpose.moderation.down` — bytes received on subscriptions opened for
* that purpose, resolved through the subscription id the frame carries.
*
* The upload counters answer "who is asking"; this answers "who is being
* answered", which is the larger number: inbound EVENT payload is ~74 % of relay
* traffic against ~26 % outbound. Without it, a fix to the REQ churn can only be
* credited with the upload it removes, when the interesting question is how much
* of the download it was causing — every re-subscription can make the relay
* re-send everything that matches.
*/
fun relayPurposeDown(purpose: String): String = purposeKeys(purpose)[P_DOWN]
/**
* `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes.
*
* Bytes alone cannot separate "many small events delivered repeatedly" from "few
* large ones", and those want opposite fixes. With a count, `down / downn` is the
* average frame size per purpose, and the total frame count set against
* `crypto.verify.count` — which the cache pays once per event it accepts — bounds
* how much of the download is the same events arriving from different relays
* under the outbox fan-out.
*/
fun relayPurposeDownCount(purpose: String): String = purposeKeys(purpose)[P_DOWNN]
/**
* `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how
* many carried an event already delivered.
*
* [relayEventsDupBytes] measures duplication across the whole client, which says
* how much is wasted but not where. The seventh reading needs exactly this split:
* `user_profile` was 57 % of download at ~17 KB per event, and whether that is
* mostly the same events arriving from many relays or mostly distinct large ones
* points at completely different fixes — suppress redundant delivery, or stop
* fetching the large thing per relay.
*/
fun relayPurposeDupBytes(purpose: String): String = purposeKeys(purpose)[P_DUPBYTES]
private const val P_SENT = 0
private const val P_BYTES = 1
private const val P_DOWN = 2
private const val P_DOWNN = 3
private const val P_DUPBYTES = 4
private val PURPOSE_SUFFIXES = arrayOf("sent", "bytes", "down", "downn", "dupbytes")
private val PURPOSE_KEYS = ConcurrentHashMap<String, Array<String>>()
/** The five `relay.purpose.<purpose>.*` keys, indexed by the `P_` constants above. */
private fun purposeKeys(purpose: String): Array<String> = PURPOSE_KEYS.getOrPut(purpose) { Array(PURPOSE_SUFFIXES.size) { "relay.purpose.$purpose.${PURPOSE_SUFFIXES[it]}" } }
/** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */
const val PURPOSE_UNATTRIBUTED = "unattributed"
/** A REQ whose filters carry no [ExplainedFilter] purpose. */
const val PURPOSE_UNEXPLAINED = "unexplained"
/** The enum's own OTHER, renamed: bare `other` is an [HTTP_ROLES] value and a reserved segment. */
const val PURPOSE_OTHER = "otherpurpose"
/**
* The key segment for a [SubPurpose]. The one place the enum is turned into a
* key, so the reserved-segment rename cannot drift between the producer and the
* test that guards it — which is exactly how it drifted the first time.
*
* Tabled by ordinal: this runs per REQ, and `name.lowercase()` would allocate a
* fresh String each time for one of a dozen fixed answers.
*/
fun purposeKeyPart(purpose: SubPurpose): String = PURPOSE_PARTS[purpose.ordinal]
private val PURPOSE_PARTS =
Array(SubPurpose.entries.size) {
val purpose = SubPurpose.entries[it]
if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase()
}
/**
* `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already
* has open on the current connection.
*
* The distinction the churn question turns on. A REQ that opens a new
* subscription is work; a REQ that replaces one already in flight is the client
* changing its mind, and at ~1 KB each that is pure cost. Measured against
* [relaySubsSent] it says what fraction of the upload is re-subscription rather
* than subscription.
*/
fun relaySubsResent(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_SUBS_RESENT[dimIndex(mobile, foreground)]
private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent")
/** Half-open bounds, in ms, shared by the two connect-timing histograms below. */
private val CONNECT_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000)
/**
* `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the
* transport measured it.
*
* This is `onConnected`'s `pingMillis`, which `BasicOkHttpWebSocket` computes as
* `receivedResponseAtMillis - sentRequestAtMillis` and which this listener
* previously discarded. PR #3843 is the cautionary tale: `RelayObserver` derived
* the same quantity from `onConnecting -> onConnected` instead, and on a large
* fan-out published a 33.5 s median that was the client's own backlog rather than
* relay latency. Those timestamps bracket the request itself, so everything
* before it — queueing, DNS, TCP, TLS — is excluded. Zero or negative means the
* transport could not time it, and nothing is recorded rather than a fabricated 0.
*/
fun relayHandshake(
ms: Long,
mobile: Boolean,
foreground: Boolean,
): String = HANDSHAKE.key(ms, mobile, foreground)
private val HANDSHAKE = MsHistogram("relay.hs", CONNECT_BOUNDS_MS) { "${it}ms" }
/**
* `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the
* upgrade request going out: dispatcher queueing, DNS, TCP, TLS.
*
* `(onConnected wall clock - onConnecting wall clock) - handshake`. This is the
* share of connect latency the app is responsible for rather than the relay, and
* it is what decides whether a high never-became-ready rate is relays being
* unreachable or ~500 simultaneous dials saturating name resolution and sockets.
* The dispatcher's own cap is not the constraint on a phone
* (`maxRequests = 1024`, `maxRequestsPerHost = 10`), so a large value here points
* at resolution and socket setup, not at a queue.
*/
fun relayDialGap(
ms: Long,
mobile: Boolean,
foreground: Boolean,
): String = DIAL_GAP.key(ms, mobile, foreground)
private val DIAL_GAP = MsHistogram("relay.gap", CONNECT_BOUNDS_MS) { "${it}ms" }
/**
* `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many
* carried an event id already delivered recently.
*
* The outbox model asks many relays for the same authors, so one event is
* delivered once per relay that carries it. Relay download is ~65 % of all data
* on the release build, so the duplication factor decides whether the largest
* number in the ledger is content or repetition — a question no other counter
* here can answer, and one [VERIFY_COUNT] only proxies (it counts what the cache
* accepted, not what arrived, and only while dedup-before-verify holds).
*
* [relayEventsDupBytes] is the number that matters: bytes that arrived and were
* already held.
*/
fun relayEventsSeen(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_EV_SEEN[dimIndex(mobile, foreground)]
fun relayEventsDup(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_EV_DUP[dimIndex(mobile, foreground)]
fun relayEventsDupBytes(
mobile: Boolean,
foreground: Boolean,
): String = RELAY_EV_DUPB[dimIndex(mobile, foreground)]
private val RELAY_EV_SEEN = dimKeys("relay.events.seen")
private val RELAY_EV_DUP = dimKeys("relay.events.dup")
private val RELAY_EV_DUPB = dimKeys("relay.events.dupbytes")
/**
* `relay.trigger.netid` — reconnect *decisions*, by cause, not reconnects
* performed.
*
* Two reasons this is an upper bound, both of which matter when reading it:
* `NostrClient.reconnect` emits into a debounced flow that `subscribe` /
* `count` / `publish` / `onDisconnected` also feed very frequently, so a
* teardown can be coalesced away before it runs; and the flow's initial value
* fires one teardown per client construction with no trigger attributed.
*/
fun relayTrigger(cause: String): String = "relay.trigger.$cause"
const val TRIGGER_NETID = "netid"
const val TRIGGER_TRANSPORT = "transport"
const val TRIGGER_TOR_POLICY = "torpolicy"
const val TRIGGER_CLASSIFICATION = "class"
const val TRIGGER_COLD_START = "coldstart"
const val TRIGGER_OFF = "off"
const val TRIGGER_BUZZ = "buzz"
/** `worker.scheduledPost.runs` */
fun workerRuns(worker: String): String = "worker.$worker.runs"
@@ -187,18 +786,32 @@ object UsageKeys {
const val BATTERY_DRAIN_FG = "battery.drain.fg"
const val BATTERY_DRAIN_BG = "battery.drain.bg"
fun dim(
mobile: Boolean,
foreground: Boolean,
): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}"
/** Sums every counter whose key matches all the given dot-delimited parts. */
/**
* Sums every counter whose key matches all the given dot-delimited parts.
*
* Scans segments in place rather than `key.split('.')`: [UsageSummary.from] makes
* ~54 of these passes over the whole day bucket, the usage screen builds 16
* summaries per entry on the main thread, and the churn counters roughly tripled
* the key count — none of which can ever match (that is what
* `noNewKeyContainsAReservedSegment` guarantees), so every split was pure waste.
*/
fun Map<String, Long>.sumMatching(vararg parts: String): Long {
var total = 0L
for ((key, value) in this) {
val segments = key.split('.')
if (parts.all { it in segments }) total += value
if (parts.all { key.hasSegment(it) }) total += value
}
return total
}
/** True when `segment` is one of this key's whole dot-delimited segments. */
private fun String.hasSegment(segment: String): Boolean {
var from = 0
while (from <= length) {
var end = indexOf('.', from)
if (end < 0) end = length
if (end - from == segment.length && regionMatches(from, segment, 0, segment.length)) return true
from = end + 1
}
return false
}
}
@@ -0,0 +1,142 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
/**
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
* returns null for.
*
* It exists so those files have somewhere to land other than the video player: an unknown blob
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
* which would try to download the blob just to rediscover the type the event already declared.
*/
@Composable
fun FileAttachmentCard(
url: String,
description: String?,
mimeType: String?,
sizeInBytes: Long?,
) {
val uriHandler = LocalUriHandler.current
val filename = remember(url) { extractFilename(url) }
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
Column(
modifier =
MaterialTheme.colorScheme.innerPostModifier
.fillMaxWidth()
.clickable { uriHandler.openUri(url) },
) {
FileAttachmentRow(
symbol = MaterialSymbols.AttachFile,
// The alt/content text names the file for a human ("Webxdc app: Quake");
// the hashed URL basename is the fallback when the event omits it.
title = description?.ifBlank { null } ?: filename,
subtitle = subtitle,
titleMaxLines = 2,
)
Spacer(modifier = DoubleVertSpacer)
}
}
/**
* The icon + title + subtitle row shared by every card that stands in for a file it can't
* render inline: this one and the PDF placeholder/skeleton in
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
*/
@Composable
internal fun FileAttachmentRow(
symbol: MaterialSymbol,
title: String,
subtitle: String?,
titleMaxLines: Int = 1,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = symbol,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
maxLines = titleMaxLines,
overflow = TextOverflow.Ellipsis,
)
if (subtitle != null) {
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
}
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
private fun fileSubtitle(
mimeType: String?,
sizeInBytes: Long?,
): String? =
listOfNotNull(
mimeType?.ifBlank { null }?.let(::prettyMime),
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
).joinToString(" · ").ifEmpty { null }
@@ -25,6 +25,8 @@ import android.net.Uri
import androidx.annotation.VisibleForTesting
import androidx.core.content.FileProvider
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -37,6 +39,7 @@ object ShareHelper {
private const val DEFAULT_IMAGE_EXTENSION = "jpg"
private const val DEFAULT_VIDEO_EXTENSION = "mp4"
private const val SHARED_FILE_PREFIX = "shared_media"
private const val GENERIC_BINARY_MIME_TYPE = "application/octet-stream"
data class SharableFile(
val uri: Uri,
@@ -65,6 +68,25 @@ object ShareHelper {
private val MP4_BRAND_MP42 = "mp42".toByteArray()
private val MOV_BRAND_QT = "qt ".toByteArray()
/**
* Picks the MIME type to put on an `ACTION_SEND` intent.
*
* `Intent.type` has to be a real `type/subtype` — an `IntentFilter` matches the two halves
* separately, so a slash-less value matches nothing and the chooser opens empty. The declared
* type is author-supplied and may be unusable (see [normalizeMimeType]), so it is treated as a
* hint; [fileExtension] is the safer signal because [getMediaExtension] sniffs it from the
* file's magic numbers rather than trusting the event.
*/
internal fun resolveShareMimeType(
declaredMimeType: String?,
fileExtension: String,
): String =
normalizeMimeType(declaredMimeType)
?: mimeTypeMap[fileExtension.lowercase()]
// Unreachable today: getMediaExtension only ever returns keys of mimeTypeMap. Kept so
// the return type stays a well-formed MIME if that ever stops holding.
?: GENERIC_BINARY_MIME_TYPE
suspend fun getSharableUriFromUrl(
context: Context,
imageUrl: String,
@@ -1103,7 +1103,7 @@ private suspend fun shareImageFile(
val (uri, fileExtension) = ShareHelper.getSharableUriFromUrl(context, videoUri)
// Determine mime type, use provided or derive from extension
val determinedMimeType = mimeType ?: "image/$fileExtension"
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, fileExtension)
// Create share intent
val shareIntent =
@@ -1161,7 +1161,7 @@ private suspend fun shareVideoFile(
sharedFile = sharableFile
// Determine mime type
val determinedMimeType = mimeType ?: "video/$extension"
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension)
// Create share intent
val shareIntent =
@@ -1227,7 +1227,7 @@ private suspend fun shareLocalVideoFile(
val (uri, extension) = ShareHelper.getSharableUriForLocalVideo(context, localFile)
// Determine mime type
val determinedMimeType = mimeType ?: "video/$extension"
val determinedMimeType = ShareHelper.resolveShareMimeType(mimeType, extension)
// Create share intent
val shareIntent =
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalWindowInfo
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.graphics.createBitmap
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
private fun FilenameRow(
filename: String,
subtitle: String,
) {
Row(
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Icon(
symbol = MaterialSymbols.PictureAsPdf,
contentDescription = null,
modifier = Size20Modifier,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(modifier = Modifier.weight(1f)) {
Text(
text = filename,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
}
}
}
) = FileAttachmentRow(
symbol = MaterialSymbols.PictureAsPdf,
title = filename,
subtitle = subtitle,
)
private fun renderFirstPage(
file: java.io.File,
@@ -50,6 +50,9 @@ import androidx.navigation.compose.composable
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
@@ -138,6 +141,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
@@ -264,6 +268,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
@@ -341,6 +346,15 @@ fun AppNavigation(
CompositionLocalProvider(
LocalScreenLayout provides screenLayout,
LocalTabReselectCoordinator provides tabReselectCoordinator,
// Provide the shared finder CompositionLocals so any commons composable that
// uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note)
// overloads works when rendered on Android (they error() if unprovided). Android's
// own UI uses the AccountViewModel overloads and doesn't strictly need these, but
// providing them removes the runtime trap for shared composables reaching the
// logged-in tree. (The :napplet process never renders these composables.)
LocalUserFinder provides accountViewModel.dataSources().userFinder,
LocalUserFinderAccount provides accountViewModel.account,
LocalEventFinder provides accountViewModel.dataSources().eventFinder,
) {
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
Box(Modifier.fillMaxSize()) {
@@ -579,6 +593,7 @@ fun BuildNavigation(
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
@@ -747,6 +762,14 @@ fun BuildNavigation(
)
}
composableFromEndArgs<Route.ConcordInviteLinks> {
ConcordInviteLinksScreen(
communityId = it.communityId,
accountViewModel = accountViewModel,
nav = nav,
)
}
composableFromEndArgs<Route.ConcordEdit> {
ConcordEditScreen(
communityId = it.communityId,
@@ -20,13 +20,11 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalDensity
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
}
}
}
/**
* A [BackHandler] that steps aside while the soft keyboard is on screen.
*
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
* When that pop happens while the keyboard is still up, it races the predictive-back window
* animation against the IME's close animation. On release builds — fast enough that the window
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
* screen).
*
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
*/
@Composable
fun KeyboardAwareBackHandler(
enabled: Boolean = true,
onBack: () -> Unit,
) {
val keyboardState by keyboardAsState()
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
}
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.ui.navigation.bottombars
import android.os.Build
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import kotlinx.serialization.Serializable
/**
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
* Order in this enum has no semantic meaning.
*/
@Serializable
enum class NavBarItem {
@@ -84,6 +84,18 @@ enum class NavBarItem {
FAVORITE_ALGO_FEEDS,
}
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
/**
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
*/
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
data class NavBarItemDef(
val id: NavBarItem,
val labelRes: Int,
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
// Ordered membership lists for each drawer section. The drawer renders these by looking up
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
// matching section list below — not two separate files.
val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
/**
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
*/
data class NavBarCategory(
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
)
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
listOf(
NavBarCategory(
R.string.bottom_bar_category_main,
MaterialSymbols.Home,
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_chats,
MaterialSymbols.Group,
listOf(
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_you,
MaterialSymbols.AccountCircle,
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_feeds,
MaterialSymbols.Subscriptions,
listOf(
NavBarItem.ARTICLES,
NavBarItem.LONGS,
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_apps,
MaterialSymbols.Apps,
listOf(
NavBarItem.BROWSER,
NavBarItem.FAVORITE_APPS,
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
),
NavBarCategory(
R.string.bottom_bar_category_other,
MaterialSymbols.Settings,
listOf(
NavBarItem.SETTINGS,
),
),
)
val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
@@ -584,42 +582,17 @@ fun ListContent(
accountViewModel: AccountViewModel,
nav: INav,
) {
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
// Side Menu settings screen. Empty (the default) means the full stock drawer.
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
Column(modifier) {
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
CollapsibleSection(title = R.string.drawer_section_create) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
CollapsibleSection(title = R.string.drawer_section_system) {
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
NavBarCatalog[NavBarItem.SETTINGS]?.let {
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
DrawerSections.forEach { section ->
// Keyed by section: hiding the last row of a section removes it from the drawer
// entirely, and without a key the sections below would slide up into its slots and
// inherit its CollapsibleSection expanded/collapsed state.
key(section.id) {
CatalogSection(section, hidden, accountViewModel, nav)
}
}
@@ -634,22 +607,64 @@ fun ListContent(
}
}
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
@Composable
private fun CreateRows(nav: INav) {
NavigationRow(
title = R.string.share_hls_video,
icon = MaterialSymbols.SettingsInputAntenna,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.NewHlsVideo,
)
if (isDebug) {
NavigationRow(
title = R.string.route_chess,
icon = MaterialSymbols.ChessKnight,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
}
}
/**
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
* Profile gets the primary-colored tint; every other item uses onBackground.
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
*
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
* System carries the relay-status row (not a catalog destination — it shows a live counter).
*/
@Composable
fun CatalogSection(
titleRes: Int,
ids: List<NavBarItem>,
section: DrawerSection,
hidden: Set<NavBarItem>,
accountViewModel: AccountViewModel,
nav: INav,
) {
val primary = MaterialTheme.colorScheme.primary
val onBackground = MaterialTheme.colorScheme.onBackground
CollapsibleSection(title = titleRes) {
ids.forEach { id ->
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
if (visible.isEmpty() && !section.hasFixedRows) return
CollapsibleSection(title = section.titleRes) {
when (section.id) {
DrawerSectionId.CREATE -> CreateRows(nav)
DrawerSectionId.SYSTEM ->
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
nav.closeDrawer()
nav.nav(Route.EditRelays)
},
)
else -> {}
}
visible.forEach { id ->
NavBarCatalog[id]?.let { def ->
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
if (def.id == NavBarItem.SCHEDULED_POSTS) {
@@ -0,0 +1,104 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* Which drawer rows the user cannot hide.
*
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
* so it is present by construction and never appears in the hidden set.
*/
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
/**
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
*
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
* account's drawer at the moment they first touched the setting, and every later release would have
* to migrate saved lists to introduce a screen.
*/
object DrawerItemVisibility {
fun isVisible(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Boolean = item in MandatoryDrawerItems || item !in hidden
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
fun toggle(
hidden: Set<NavBarItem>,
item: NavBarItem,
): Set<NavBarItem> =
when {
item in MandatoryDrawerItems -> hidden
item in hidden -> hidden - item
else -> hidden + item
}
/**
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
* it runs on decode, on an external sync, and on every write — so a value synced from another
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
*
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
* preserving it means editing the drawer on that device doesn't silently clear the choice the
* user made on another one.
*/
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
/** The rows of [section] to render, in the section's fixed order. */
fun visibleItems(
section: DrawerSection,
hidden: Set<NavBarItem>,
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
fun hiddenCount(
section: DrawerSection,
hidden: Set<NavBarItem>,
): Int = section.items.count { !isVisible(hidden, it) }
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
fun hideAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
/** Shows every row of [section] again. */
fun showAll(
hidden: Set<NavBarItem>,
section: DrawerSection,
): Set<NavBarItem> = hidden - section.items.toSet()
/** Total hidden rows across every section — the count the settings screen shows at the top. */
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
}
@@ -0,0 +1,153 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.drawer
import android.os.Build
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
/**
* The drawer's layout: which destinations it lists, under which heading, in which order.
*
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
* filed into exactly one section.
*
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
* which rows are visible — see [DrawerItemVisibility].
*/
@Immutable
data class DrawerSection(
val id: DrawerSectionId,
val titleRes: Int,
val icon: MaterialSymbol,
val items: List<NavBarItem>,
/**
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
*/
val hasFixedRows: Boolean = false,
)
/**
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
* compile error and nothing to fail a test.
*/
enum class DrawerSectionId {
YOU,
NAVIGATE,
FEEDS,
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
CREATE,
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
SYSTEM,
}
private val DrawerNavigateItems: List<NavBarItem> =
listOf(
NavBarItem.HOME,
NavBarItem.MESSAGES,
NavBarItem.VIDEO,
NavBarItem.BROWSER,
NavBarItem.DISCOVER,
NavBarItem.NOTIFICATIONS,
)
private val DrawerYouItems: List<NavBarItem> =
listOf(
NavBarItem.PROFILE,
NavBarItem.MY_LISTS,
NavBarItem.BOOKMARKS,
NavBarItem.WEB_BOOKMARKS,
NavBarItem.DRAFTS,
NavBarItem.SCHEDULED_POSTS,
NavBarItem.INTEREST_SETS,
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.BLOSSOM_DATA,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
private val DrawerFeedsItems: List<NavBarItem> =
listOfNotNull(
NavBarItem.ARTICLES,
NavBarItem.PICTURES,
NavBarItem.SHORTS,
NavBarItem.LONGS,
NavBarItem.PODCAST_EPISODES,
NavBarItem.PODCASTS,
NavBarItem.MUSIC_TRACKS,
NavBarItem.MUSIC_PLAYLISTS,
NavBarItem.POLLS,
NavBarItem.PRODUCTS,
NavBarItem.WORKOUTS,
NavBarItem.GIT_REPOSITORIES,
NavBarItem.HIGHLIGHTS,
NavBarItem.LIVE_STREAMS,
NavBarItem.NESTS,
NavBarItem.COMMUNITIES,
NavBarItem.PUBLIC_CHATS,
NavBarItem.RELAY_GROUPS,
NavBarItem.CONCORD,
NavBarItem.GEOHASH_CHATS,
NavBarItem.CALENDARS,
NavBarItem.CALENDAR_COLLECTIONS,
NavBarItem.SOFTWARE_APPS,
// Favorites can be pinned as inline tabs that render on a cross-process surface
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
NavBarItem.NAPPLETS,
NavBarItem.NSITES,
NavBarItem.FOLLOW_PACKS,
NavBarItem.BADGES,
NavBarItem.EMOJI_SETS,
)
val DrawerSections: List<DrawerSection> =
listOf(
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
)
/**
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
* missing, and fails on anything else — that's what keeps a newly added destination from silently
* skipping both the drawer and its settings screen.
*/
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
@@ -0,0 +1,89 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.navigation.navs
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.ime
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
/** How long to wait for the IME inset to reach zero before navigating anyway. */
const val IME_SETTLE_TIMEOUT_MS = 700L
/**
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
* in the app serializes the IME and window animations instead of overlapping them.
*
* Navigating while the keyboard is up races the window animation against the IME's close animation.
* On release builds — fast enough that the window animation wins — the IME
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
* freezes at the keyboard height until some later inset pass happens to rebalance it.
*
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
* has done anything, and every way out of it is a navigation.
*/
fun interface ImeSettler {
suspend fun settle()
companion object {
/** For [EmptyNav] and previews, where there is no window to read insets from. */
val None = ImeSettler { }
}
}
/**
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
* and the padding can never disagree about whether the keyboard is gone.
*
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
* guards against, navigation still proceeds rather than stranding the user on the screen.
*/
@Composable
fun rememberImeSettler(): ImeSettler {
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val keyboard = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
return remember(density, imeInsets, keyboard, focusManager) {
ImeSettler {
if (imeInsets.getBottom(density) > 0) {
focusManager.clearFocus(true)
keyboard?.hide()
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
}
}
}
}
}
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
class Nav(
val controller: NavHostController,
override val navigationScope: CoroutineScope,
/**
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
* through this class, so this is the one place that has to get it right — no screen, top bar
* or back handler needs to think about the keyboard on its way out.
*/
private val ime: ImeSettler = ImeSettler.None,
) : INav {
override val drawerState = DrawerState(DrawerValue.Closed)
@@ -63,6 +70,7 @@ class Nav(
override fun nav(route: Route) {
navigationScope.launch {
ime.settle()
if (getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
}
@@ -71,6 +79,7 @@ class Nav(
override fun nav(computeRoute: suspend () -> Route?) {
navigationScope.launch {
ime.settle()
val route = computeRoute()
if (route != null && getRouteWithArguments(route::class, controller) != route) {
controller.navigate(route)
@@ -80,6 +89,7 @@ class Nav(
override fun newStack(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(route) {
inclusive = true
@@ -91,6 +101,7 @@ class Nav(
override fun navBottomBar(route: Route) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
// Clear sibling bottom-nav entries but keep Home (the start
// destination) below, so back-swipe from any tab returns to
@@ -110,7 +121,28 @@ class Nav(
}
// Mark this entry as a tab root: hides the back arrow in canPop
// and skips the horizontal slide in composableFromEnd.
controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true
// saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a
// single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one
// pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps
// pinned, tapping the second one landed on the first one's URL, and the lookup below then threw
// `No destination with route …WebApp/<url> is on the NavController's back stack`.
//
// When the entry we asked for isn't there, take the tab fresh (no restoreState, and no
// launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel
// state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose
// destination nothing else shares still restore normally, which is what this is here for.
val entry =
runCatching { controller.getBackStackEntry(route) }.getOrNull()
?: run {
controller.navigate(route) {
popUpTo(Route.Home) {
inclusive = false
saveState = true
}
}
runCatching { controller.getBackStackEntry(route) }.getOrNull()
}
entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true)
}
}
@@ -149,6 +181,7 @@ class Nav(
override fun popBack() {
navigationScope.launch {
ime.settle()
controller.navigateUp()
}
}
@@ -159,6 +192,7 @@ class Nav(
klass: KClass<T>,
) {
navigationScope.launch {
ime.settle()
controller.navigate(route) {
popUpTo(klass) { inclusive = true }
}
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
fun rememberNav(): Nav {
val navController = rememberNavController()
val scope = rememberCoroutineScope()
val ime = rememberImeSettler()
return remember(navController, scope) {
Nav(navController, scope)
return remember(navController, scope, ime) {
Nav(navController, scope, ime)
}
}
@@ -457,6 +457,8 @@ sealed class Route {
@Serializable object BottomBarSettings : Route()
@Serializable object DrawerSettings : Route()
@Serializable object HomeTabsSettings : Route()
@Serializable object ProfileUiSettings : Route()
@@ -832,6 +834,10 @@ sealed class Route {
val communityId: String,
) : Route()
@Serializable data class ConcordInviteLinks(
val communityId: String,
) : Route()
@Serializable object ConcordCreate : Route()
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
@@ -0,0 +1,147 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.note
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalClipboard
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
import com.vitorpamplona.amethyst.ui.components.cachedTranslation
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.note.types.displayedNoteText
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.coroutines.launch
/** Both texts of a translated note, held while the user picks which one to copy. */
@Immutable
data class CopyTextChoice(
val original: String,
val translated: String,
)
/**
* The "Copy Text" flow shared by every menu that copies an event's text.
*
* The copy menus sit far from the `TranslatableRichTextViewer` that rendered (and possibly
* translated) the note, so instead of plumbing the translated string down the hierarchy this
* flow re-derives it from [cachedTranslation]: the process-wide translation cache keyed by
* (content, language settings). By the time any copy menu is reachable the note has been
* rendered, which is what populated that cache — so a hit means the user is looking at a
* translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly.
*
* What gets copied — and what the cache is keyed on — is [displayedNoteText], the same string
* the viewer rendered, not the raw event content: a NIP-14 subject is part of what the user is
* reading and of what was translated.
*
* Returns the click handler for the menu entry, taking the note the menu belongs to and the
* version of it the screen is showing (the same note unless the post was edited — the body
* comes from the version, the subject from the note itself, exactly as the viewer composes
* them). [onCopied] runs after the text lands on the
* clipboard, [onDismiss] when the chooser is cancelled without copying **or** when the note
* can't be decrypted at all (a read-only account, a refused signer) so the menu still closes
* instead of hanging on a copy that will never happen. Callers must keep their menu in
* composition until one of the two runs, because the chooser dialog is emitted from this
* composable.
*/
@Composable
fun copyNoteTextAction(
accountViewModel: AccountViewModel,
onCopied: () -> Unit,
onDismiss: () -> Unit,
): (note: Note, versionShown: Note) -> Unit {
val clipboardManager = LocalClipboard.current
val scope = rememberCoroutineScope()
val choice = remember { mutableStateOf<CopyTextChoice?>(null) }
val copy: (String) -> Unit = { text ->
scope.launch {
clipboardManager.setText(text)
onCopied()
}
}
choice.value?.let { options ->
CopyTextChooserDialog(
onCopyOriginal = {
choice.value = null
copy(options.original)
},
onCopyTranslated = {
choice.value = null
copy(options.translated)
},
onDismiss = {
choice.value = null
onDismiss()
},
)
}
return { note, versionShown ->
accountViewModel.decryptOrNull(versionShown) { decrypted ->
if (decrypted == null) {
onDismiss()
} else {
val original = displayedNoteText(note, decrypted)
val translated = cachedTranslation(original, accountViewModel)
if (translated == null) {
copy(original)
} else {
choice.value = CopyTextChoice(original, translated)
}
}
}
}
}
@Composable
fun CopyTextChooserDialog(
onCopyOriginal: () -> Unit,
onCopyTranslated: () -> Unit,
onDismiss: () -> Unit,
) {
M3ActionDialog(
title = stringRes(R.string.copy_text),
onDismiss = onDismiss,
) {
M3ActionSection {
M3ActionRow(
icon = MaterialSymbols.ContentCopy,
text = stringRes(R.string.copy_text_original),
onClick = onCopyOriginal,
)
M3ActionRow(
icon = MaterialSymbols.Translate,
text = stringRes(R.string.copy_text_translated),
onClick = onCopyTranslated,
)
}
}
}
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.model.AddressableNote
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.model.textNoteModifications
import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
@@ -299,20 +300,31 @@ fun CardBody(
)
}
// "Copy Text" copies the version on screen: an edited post renders its newest modification
// by default (EditState.updateModifications), and the 3-dot menu already copies that one.
// Reading `edits` is a hard-referenced in-memory fold, so no cache scan here.
val noteVersionToCopy = remember(note) { note.textNoteModifications().lastOrNull() ?: note }
// When the rendered note was translated, tapping Copy Text opens a chooser
// (Copy Original / Copy Translated) on top of this popup; the popup stays up
// until the flow resolves so the chooser survives in composition.
val copyNoteText =
copyNoteTextAction(
accountViewModel = accountViewModel,
onCopied = {
showToast(R.string.copied_note_text_to_clipboard)
onDismiss()
},
onDismiss = onDismiss,
)
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
NoteQuickActionItem(
icon = MaterialSymbols.ContentCopy,
label = stringRes(R.string.quick_action_copy_text),
) {
accountViewModel.decrypt(note) {
scope.launch {
clipboardManager.setText(it)
showToast(R.string.copied_note_text_to_clipboard)
}
}
onDismiss()
copyNoteText(note, noteVersionToCopy)
}
VerticalDivider(color = primaryLight)
NoteQuickActionItem(
@@ -35,6 +35,7 @@ import com.vitorpamplona.amethyst.ui.components.util.setText
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.QuickActionAlertDialogOneButton
import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.LightRedColor
@@ -129,14 +130,21 @@ fun noteActionSections(
)
}
// When the rendered note was translated, Copy Text opens a chooser (Copy
// Original / Copy Translated) on top of the menu; the menu dismisses only
// after the flow resolves so the chooser survives in composition.
val copyNoteText =
copyNoteTextAction(
accountViewModel = accountViewModel,
onCopied = handlers.onDismiss,
onDismiss = handlers.onDismiss,
)
val copyAndShare =
buildList {
add(
NoteAction(MaterialSymbols.ContentCopy, stringRes(R.string.copy_text)) {
accountViewModel.decrypt(noteVersionToCopy) {
scope.launch { clipboardManager.setText(it) }
}
handlers.onDismiss()
copyNoteText(note, noteVersionToCopy)
},
)
add(
@@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
import com.vitorpamplona.amethyst.ui.stringRes
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
// resolution on low-density screens. Deriving the size from the available column width keeps
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
) {
val event = (note.event as? FileHeaderEvent) ?: return
val fullUrl = event.url() ?: return
val mimeType = remember(note) { event.mimeType() }
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
val content: BaseMediaContent =
remember(note) {
val blurHash = event.blurhash()
val thumbHash = event.thumbhash()
val hash = event.hash()
val dimensions = event.dimensions()
val description = event.content.ifEmpty { null } ?: event.alt()
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
val uri = note.toNostrUri()
val mimeType = event.mimeType()
if (isImage) {
MediaUrlImage(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
} else {
MediaUrlVideo(
url = fullUrl,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
}
}
// The sensitivity gate wraps both branches: a content warning is about the file, not about
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
if (content == null) {
FileHeaderAttachmentCard(event, fullUrl, mimeType)
} else {
ZoomableContentView(
content = content,
roundedCorner = roundedCorner,
contentScale = contentScale,
accountViewModel = accountViewModel,
)
}
}
}
/**
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
*
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
* video event the kind itself asserts nothing about how to render the payload. A null here means
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
*/
internal fun FileHeaderEvent.toMediaContent(
note: Note,
url: String,
mimeType: String?,
): BaseMediaContent? {
val blurHash = blurhash()
val thumbHash = thumbhash()
val hash = hash()
val dimensions = dimensions()
val description = fileDescription()
val uri = note.toNostrUri()
return when (RichTextParser.classifyMedia(url, mimeType)) {
MediaContentKind.IMAGE ->
MediaUrlImage(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.VIDEO ->
MediaUrlVideo(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
authorName = note.author?.toBestDisplayName(),
mimeType = mimeType,
thumbhash = thumbHash,
)
MediaContentKind.PDF ->
MediaUrlPdf(
url = url,
description = description,
hash = hash,
blurhash = blurHash,
dim = dimensions,
uri = uri,
mimeType = mimeType,
thumbhash = thumbHash,
)
null -> null
}
}
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
@Composable
internal fun FileHeaderAttachmentCard(
event: FileHeaderEvent,
url: String,
mimeType: String?,
) {
val description = remember(event) { event.fileDescription() }
val sizeInBytes = remember(event) { event.size()?.toLong() }
FileAttachmentCard(
url = url,
description = description,
mimeType = mimeType,
sizeInBytes = sizeInBytes,
)
}
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
import com.vitorpamplona.amethyst.commons.util.prettyMime
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.model.Note
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
}
}
internal fun prettyMime(mime: String): String =
when (mime) {
"application/vnd.android.package-archive" -> "APK"
"application/vnd.apple.ipa" -> "IPA"
"application/x-apple-diskimage" -> "DMG"
"application/vnd.apple.installer+xml" -> "PKG"
"application/x-msi" -> "MSI"
"application/vnd.appimage" -> "AppImage"
"application/vnd.flatpak" -> "Flatpak"
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
"application/x-executable" -> "ELF"
"application/x-mach-binary" -> "Mach-O"
"application/vnd.microsoft.portable-executable" -> "EXE"
"application/vsix" -> "VSIX"
"application/x-chrome-extension" -> "CRX"
"application/x-xpinstall" -> "XPI"
"application/wasm" -> "WASM"
"application/webbundle" -> "Web Bundle"
else -> mime
}
internal fun formatBytes(bytes: Long): String {
if (bytes < 1024L) return "$bytes B"
val kb = bytes / 1024.0
@@ -69,6 +69,27 @@ enum class ReplyRenderType {
NONE,
}
/**
* The text [RenderTextEvent] puts on screen for [note] given its decrypted [body]: a NIP-14
* subject the body doesn't already repeat is prepended to it.
*
* This is the exact string handed to `TranslatableRichTextViewer`, so it is also the key the
* translation cache stores the result under. The copy-text menus look their translation up by
* the same function — keying on the raw body instead would miss the entry for every
* subject-carrying note and silently copy the untranslated text.
*/
fun displayedNoteText(
note: Note,
body: String,
): String {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null }
return if (subject != null && !body.contains(subject, ignoreCase = true)) {
"$subject\n\n$body"
} else {
body
}
}
@Composable
fun RenderTextEvent(
note: Note,
@@ -177,15 +198,7 @@ fun RenderTextEvent(
body
}
val eventContent =
remember(newBody) {
val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null }
if (!subject.isNullOrBlank() && !newBody.contains(subject, ignoreCase = true)) {
"$subject\n\n$newBody"
} else {
newBody
}
}
val eventContent = remember(newBody) { displayedNoteText(note, newBody) }
// A boosted note inside a zap/nutzap/onchain activity card is always shown as a
// compact 2-line preview, even when the logged-in user is only a zap-split
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -88,7 +89,9 @@ fun VideoDisplay(
val content: BaseMediaContent =
remember(note) {
val description = videoEvent.content.ifBlank { null } ?: event.alt()
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
val uri = note.toNostrUri()
if (isImage) {
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
val reasons = remember(note) { collectContentWarningReasons(event) }
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
val content by
remember(note) {
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
@@ -1585,6 +1586,29 @@ class AccountViewModel(
account.decryptContent(note)?.let { onReady(it) }
}
/**
* [decrypt] that always answers: [onReady] gets null when the content can't be read — a
* read-only account holding no key, a DM this account isn't part of, or a signer that
* refused/timed out. [decrypt] stays silent in those cases, which strands callers that must
* finish either way (a menu that only closes once the copy resolves, say).
*/
fun decryptOrNull(
note: Note,
onReady: (String?) -> Unit,
) = launchSigner {
val decrypted =
try {
account.decryptContent(note)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
// launchSigner still gets the exception to toast/log the signer failure.
onReady(null)
throw e
}
onReady(decrypted)
}
/**
* Runs an action that has both a tracked and a direct broadcast variant,
* picking the path the user selected via the "Tracked broadcasts" setting.
@@ -1986,6 +2010,15 @@ class AccountViewModel(
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
if (account.applyHiddenDrawerItems(items)) {
launchSigner { account.sendNewAppSpecificData() }
}
}
fun changeBottomBarItems(items: List<BottomBarEntry>) {
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
@@ -2096,7 +2129,7 @@ class AccountViewModel(
fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key)
override fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex)
override fun getOrCreateUser(pubkey: HexKey): User = LocalCache.getOrCreateUser(pubkey)
fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex)
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
import com.vitorpamplona.amethyst.ui.note.VerticalDotsIcon
import com.vitorpamplona.amethyst.ui.note.copyNoteTextAction
import com.vitorpamplona.amethyst.ui.note.elements.DropDownParams
import com.vitorpamplona.amethyst.ui.note.elements.observeBookmarksFollowsAndAccount
import com.vitorpamplona.amethyst.ui.note.externalLinkForNote
@@ -186,16 +187,21 @@ fun BookmarkGroupItemOptionsMenu(
}
}
// When the rendered note was translated, Copy Text opens a chooser (Copy
// Original / Copy Translated) on top of the menu; the menu dismisses only
// after the flow resolves so the chooser survives in composition.
val copyNoteText =
copyNoteTextAction(
accountViewModel = accountViewModel,
onCopied = onDismiss,
onDismiss = onDismiss,
)
// Copy & Share section
M3ActionSection {
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_text)) {
val lastNoteVersion = (editState?.value as? GenericLoadable.Loaded)?.loaded?.modificationToShow?.value ?: note
accountViewModel.decrypt(lastNoteVersion) {
scope.launch {
clipboardManager.setText(it)
}
}
onDismiss()
copyNoteText(note, lastNoteVersion)
}
M3ActionRow(icon = MaterialSymbols.ContentCopy, text = stringRes(R.string.copy_user_pubkey)) {
note.author?.let {
@@ -49,8 +49,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedMagnifierProb
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedSurfaceController
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.ImeEvent
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.MagnifierFrame
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseSelectionGeometry
import org.json.JSONObject
import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.parseImeEvent
import java.util.concurrent.atomic.AtomicLong
/**
@@ -337,39 +336,6 @@ class EmbeddedWebAppController(
putLong(NappletBrowserContract.KEY_MAG_REQ_T, SystemClock.elapsedRealtimeNanos())
}
private fun parseImeEvent(payload: String): ImeEvent? {
val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null
return when (o.optString("type")) {
"ime.focus" ->
ImeEvent.Focus(
inputType = o.optString("inputType", "text"),
enterKeyHint = o.optString("enterKeyHint", ""),
multiline = o.optBoolean("multiline", false),
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.blur" -> ImeEvent.Blur
"ime.state" ->
ImeEvent.State(
text = o.optString("text", ""),
selStart = o.optInt("selStart", 0),
selEnd = o.optInt("selEnd", 0),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.pagesel" ->
ImeEvent.PageSelection(
active = o.optBoolean("active", false),
text = o.optString("text", ""),
geometry = parseSelectionGeometry(o.optJSONObject("geom")),
)
"ime.scroll" -> ImeEvent.Scroll(active = o.optBoolean("active", false))
"ime.carettap" -> ImeEvent.CaretTap(geometry = parseSelectionGeometry(o.optJSONObject("geom")))
else -> null
}
}
private inline fun send(
what: Int,
crossinline block: Bundle.() -> Unit,
@@ -109,9 +109,7 @@ class AgentConsoleViewModel : ViewModel() {
relay?.let {
val newlyJoined = BuzzWorkspaces.join(it)
viewModelScope.launch { account.relayAuthLedger.setDecision(it.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
if (newlyJoined) reconnectPoolAfterJoin(account.client)
}
refresh()
}
@@ -130,9 +130,7 @@ class BuzzDmListViewModel : ViewModel() {
val newlyJoined = BuzzWorkspaces.join(relay)
viewModelScope.launch { account.relayAuthLedger.setDecision(relay.url, RelayAuthDecision.ALLOW) }
// A join makes the relay first-party; if the socket was already open its one-shot AUTH
// challenge was spent unauthenticated, so reconnect to re-challenge and authenticate.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
if (newlyJoined) reconnectPoolAfterJoin(account.client)
// Paint from cache BEFORE any network work. [discoverMemberChannels] learns the channel ids
// from a relay round-trip, so waiting on it left the Direct Messages section visibly empty
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.buzz
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
* Re-dials the whole relay pool after a Buzz workspace join.
*
* NIP-42 sends its AUTH challenge once, on connect. If the socket was already open
* before the join (the common case — the relay is in the user's lists and connected
* at startup), that challenge was spent while the relay was still NOT first-party,
* so the connection is unauthenticated and every `#p=me`-gated read on it is
* refused. Joining makes the relay first-party (see `AuthCoordinator.isFirstParty`);
* this forces the relay to re-challenge so the connection authenticates.
*
* Shared by the three join sites that need the re-challenge, both to keep the
* reconnect flags identical and to give the churn ledger one place to attribute from:
* without the counter, `Σ(relay.trigger.*)` would only account for the
* connectivity-driven teardowns `RelayProxyClientConnector` reports, and a full pool
* teardown is the most expensive thing either can do.
*
* `BuzzInviteScreen` is a fourth join+pre-approve site that deliberately does NOT
* reconnect — it hands off to the in-app browser rather than reading a `#p=me`-gated
* subscription — so `relay.trigger.buzz` undercounts joins, not re-challenges.
*/
internal fun reconnectPoolAfterJoin(client: INostrClient) {
// Guarded like every other ledger write that reaches the application singleton
// (MediaPlayTimeTracker, the workers): a diagnostics counter must never break a
// user-visible join, and `Amethyst.instance` is lateinit.
runCatching { Amethyst.instance.resourceUsage.add(UsageKeys.relayTrigger(UsageKeys.TRIGGER_BUZZ), 1) }
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
}
@@ -107,14 +107,8 @@ class BuzzRelayImportViewModel : ViewModel() {
val newlyJoined = BuzzWorkspaces.join(normalized)
viewModelScope.launch { account.relayAuthLedger.setDecision(normalized.url, RelayAuthDecision.ALLOW) }
// NIP-42 sends its AUTH challenge once, on connect. If the socket was already open before this
// join (the common case — the relay is in the user's lists and connected at startup), that
// challenge was spent while the relay was still NOT first-party, so the connection is
// unauthenticated and the persistent group-roster (39002) subscription is refused. Joining
// makes the relay first-party (see AuthCoordinator.isFirstParty); force a reconnect so the
// relay re-challenges and the connection authenticates — unlocking the roster (Join gate) and
// every other `#p=me`-gated read on the shared socket.
if (newlyJoined) account.client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
// Unlocks the persistent group-roster (39002) subscription — see [reconnectPoolAfterJoin].
if (newlyJoined) reconnectPoolAfterJoin(account.client)
// Track "already added" against the live kind-10009 list, scoped to this relay, so the rows
// follow every add/remove — from here, from the channel's top bar, or from another device.
@@ -116,7 +116,7 @@ class ChatroomNip04HistorySubAssembler(
// so a late callback can't move another room's cursors. newEose (framework bookkeeping) runs anyway.
val myCursors = cursorsFor(key)
return object : SubscriptionListener {
override fun onEvent(
override suspend fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
import androidx.compose.foundation.layout.Box
@@ -86,7 +87,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton
import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.Nav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
@@ -169,7 +169,7 @@ fun NewGroupDMScreen(
WatchAndLoadMyEmojiList(accountViewModel)
KeyboardAwareBackHandler {
BackHandler {
accountViewModel.launchSigner {
postViewModel.sendDraftSync()
postViewModel.cancel()
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.ui.actions.UrlUserTagOutputTransformation
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.components.ThinPaddingTextField
import com.vitorpamplona.amethyst.ui.navigation.bottombars.KeyboardAwareBackHandler
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
@@ -110,7 +110,7 @@ fun PrivateMessageEditFieldRow(
onSendNewMessage: () -> Unit,
nav: INav,
) {
KeyboardAwareBackHandler {
BackHandler {
if (channelScreenModel.message.text.isNotBlank()) {
accountViewModel.launchSigner {
channelScreenModel.sendDraftSync()
@@ -167,11 +167,19 @@ fun ConcordChannelListScreen(
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
// so the affordance waits for the key too.
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
// already avoids for the Roles… menu.
val canManageChannels =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
} == true
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
} == true &&
session?.controlPlaneKeys()?.canWrite == true
// channelIdHex == null → create; else → rename that channel.
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
@@ -234,10 +242,21 @@ fun ConcordChannelListScreen(
}
},
actions = {
// Rank + the Control write key (CORD-02 §2), like [canManageChannels] above.
val canEdit =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
} == true &&
session?.controlPlaneKeys()?.canWrite == true
// Minting an invite hands out a working key to the community, so it takes
// CREATE_INVITE like any other privileged action. This button used to be the one
// control on the screen with no gate at all.
val canInvite =
state?.authority?.let {
it.isOwner(account.signer.pubKey) ||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
} == true
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
@@ -248,22 +267,24 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
}
}
IconButton(
enabled = !minting,
onClick = {
minting = true
scope.launch {
try {
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
if (canInvite) {
IconButton(
enabled = !minting,
onClick = {
minting = true
scope.launch {
try {
inviteLink = account.concord.mintConcordInvite(communityId)
} finally {
// Always clear the flag — a thrown mint would otherwise leave the
// button disabled until the screen is recreated.
minting = false
}
}
}
},
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
},
) {
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
}
}
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
@@ -273,6 +294,17 @@ fun ConcordChannelListScreen(
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
}
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
// Deliberately not gated on CREATE_INVITE, unlike minting: the links listed
// there are this account's own, authored by link-signer keys only we hold.
// Gating on the bit would mean a demoted admin could no longer retire the
// links they had already handed out — exactly when that matters most.
DropdownMenuItem(
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) },
onClick = {
menuOpen = false
nav.nav(Route.ConcordInviteLinks(communityId))
},
)
DropdownMenuItem(
text = {
Text(

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