diff --git a/.claude/core-skills-plan.md b/.claude/core-skills-plan.md index 1af746f6dd..35eafb646f 100644 --- a/.claude/core-skills-plan.md +++ b/.claude/core-skills-plan.md @@ -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`. + +## 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. diff --git a/.claude/skills/event-store-semantics/SKILL.md b/.claude/skills/event-store-semantics/SKILL.md new file mode 100644 index 0000000000..1c99e335a9 --- /dev/null +++ b/.claude/skills/event-store-semantics/SKILL.md @@ -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 — 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/`. diff --git a/.claude/skills/nip85-trusted-assertions/SKILL.md b/.claude/skills/nip85-trusted-assertions/SKILL.md new file mode 100644 index 0000000000..b0b17f22ea --- /dev/null +++ b/.claude/skills/nip85-trusted-assertions/SKILL.md @@ -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", "", "wss://nip85.brainstorm.world"] +``` + +- `ServiceType(kind, type)` parses/renders `":"` — 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\ | 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 = 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": "", + "tags": [ + ["30382:rank", "

", "wss://nip85.brainstorm.world"], + ["30382:followers", "

", "wss://nip85.brainstorm.world"] + ], + "content": "" } +``` + +Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:

:`): + +```json +{ "kind": 30382, "pubkey": "

", + "tags": [ + ["d", ""], + ["rank", "87"], ["followers", "1234"], ["hops", "2"] + ], + "content": "" } +``` + +`P` asserts about an event `E` (kind 30383, addressable at `30383:

:`): + +```json +{ "kind": 30383, "pubkey": "

", + "tags": [["d", ""], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]], + "content": "" } +``` + +Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe +`{kinds:[30382], authors:["

"], "#d":["", …]}` 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::`). + +## 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` — + 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`. diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index c1019cde8f..1b72fb6977 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -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. diff --git a/.claude/skills/searchable-events/SKILL.md b/.claude/skills/searchable-events/SKILL.md new file mode 100644 index 0000000000..aad4744f68 --- /dev/null +++ b/.claude/skills/searchable-events/SKILL.md @@ -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. diff --git a/.claude/skills/searchable-events/references/searchable-kinds.md b/.claude/skills/searchable-events/references/searchable-kinds.md new file mode 100644 index 0000000000..ccae95ebfb --- /dev/null +++ b/.claude/skills/searchable-events/references/searchable-kinds.md @@ -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 "" 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. diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index ce5d002955..72643e9e40 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -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 entry for this build (the checked-in # metainfo deliberately carries none — CI is the source of truth). sed -i "s||\n |" \ @@ -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 diff --git a/.github/workflows/smoke-test-desktop.yml b/.github/workflows/smoke-test-desktop.yml index 3dafc575dd..57b992ae73 100644 --- a/.github/workflows/smoke-test-desktop.yml +++ b/.github/workflows/smoke-test-desktop.yml @@ -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 diff --git a/BUILDING.md b/BUILDING.md index 5a04173603..aa29c7ba11 100644 --- a/BUILDING.md +++ b/BUILDING.md @@ -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-.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-*-.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-*-.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--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 diff --git a/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt new file mode 100644 index 0000000000..1388e9a5bc --- /dev/null +++ b/amethyst/src/fdroid/java/com/vitorpamplona/amethyst/ui/components/CachedTranslation.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 0c59012d38..0c3936c413 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt index bfdc4466ed..f46e2e7f24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/favorites/FavoriteAppLauncher.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 70afe7bbfe..4cfab50126 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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 get() = hiddenUsers.flow.value.hiddenUsersHashCodes override val spammersHashCodes: Set 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 = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList } + + override fun outboxHomeRelays(): Set = 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 = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet() + + override fun searchOnlyRelays(): Set = searchRelayList.flow.value + + override fun followPlusAllMineWithSearchRelays(): Set = followPlusAllMineWithSearch.flow.value + + override fun commonRelays(): Set = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays } + + override fun cardHomeRelays(): Set = homeRelays.flow.value + + override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value + + override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value + + override fun declaredFollowsByOutboxRelay(): Map> = 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): Boolean = settings.changeBottomBarItems(items) + /** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */ + fun applyHiddenDrawerItems(items: Set): 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt index be17eefef0..1bf9339ae4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountConcordActions.kt @@ -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() + 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? { + 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() + 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, + authority: AuthorityResolver, + ): List { + 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() + 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index f2605013c1..9802633ddf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -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): 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt index a33799dd40..5fa6adc7ec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettings.kt @@ -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 = languages.dontTranslateFrom.value - getLanguagesSpokenByUser() @@ -322,6 +336,8 @@ class AccountMediaPreferences( @Stable class AccountNavigationPreferences( val bottomBarItems: MutableStateFlow>, + /** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */ + val hiddenDrawerItems: MutableStateFlow>, ) @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt index 4f3f51ae95..32b3900cb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSyncedSettingsInternal.kt @@ -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 = 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 = emptyList(), ) @Serializable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt index c906079d84..1f3f356995 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/CachePruner.kt @@ -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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt index 6b4d2599cb..8f502e358b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/ConcordInviteResult.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt index c1e3443d94..00062654eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Dao.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 322e793196..14bb1531d4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -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): List = keys.mapNotNull(::checkGetOrCreateUser) fun load(keys: Set): Set = 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt index 40f30c1dbb..1491bee591 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/indexerRelays/IndexerRelayListState.kt @@ -62,6 +62,28 @@ class IndexerRelayListState( suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set = 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 = 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt index 3a227ef306..eb3714dc5c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/searchRelays/SearchRelayListState.kt @@ -62,6 +62,31 @@ class SearchRelayListState( suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set = 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 = 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index b2ca4dcafc..1f2a0722e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt index 893f02ec87..14d3499371 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/DataStoreNappletStorage.kt @@ -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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 37a124c67e..f49b497538 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -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() // 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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index adff3b46f6..15ab1abd71 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 42f4ec93f5..583cda40b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -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 -> "" } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt index f94f7bc2cf..819c55b4a2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletIdentityWatch.kt @@ -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, ) { - private var job: Job? = null + private val jobs = ConcurrentHashMap() 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt index b91aabad36..8f6741e564 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLaunchRegistry.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt index 2c98ffb8fe..c923b799d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLauncher.kt @@ -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, - // 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, 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, + servers: List, + authorPubKey: HexKey, + identifier: String, + aggregateHash: HexKey?, + title: String, + requires: List, + 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" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt index 8429a0b721..89e56f0b3f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletLiveSubscriptions.kt @@ -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() private val liveSeq = AtomicInteger(0) @@ -53,6 +59,20 @@ class NappletLiveSubscriptions { val client: INostrClient, ) { val eoseSent = AtomicBoolean(false) + val deliveries = Channel(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?, - ) = 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?, ) { - 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?, - ) = 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() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt new file mode 100644 index 0000000000..3b55dad5cd --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletRelayCleartext.kt @@ -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 +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 139a935d5f..3222e810c8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -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) } } /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt index c64fb12eca..24bf6e655c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/NappletResourceFetcher.kt @@ -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:[][;base64],` 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(" + 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) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt index 664b55ed61..3ea9d1678d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkDebitPayer.kt @@ -113,7 +113,7 @@ object ClinkDebitPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt index 28a0d64fb0..a5911dc6c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ClinkOfferPayer.kt @@ -85,7 +85,7 @@ object ClinkOfferPayer { val listener = object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 3411379e4c..d90c812658 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt index ddb16c72cb..26b2930f02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationReplyReceiver.kt @@ -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}" } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index a1879a70f4..6d4791386c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -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() - 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 = 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(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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 995870ce21..b7283b37d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -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, 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt index 1966bac786..054f6f855e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/BootRelayDiagnostics.kt @@ -158,7 +158,7 @@ class BootRelayDiagnostics( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt index 4aefae6ba6..07f13fbd0b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/diagnostics/DmRelayDiagnosticsLogger.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt new file mode 100644 index 0000000000..c76800af32 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/AccountScopedSingleSubNoEoseCacheEoseManager.kt @@ -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( + client: INostrClient, + allKeys: () -> Set, + invalidateAfterEose: Boolean = false, +) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose) { + override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt index 0427380423..61c297887e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUniqueIdEoseManager.kt @@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt index cc55f02d7a..7f53cbd0aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserAndFollowListEoseManager.kt @@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt index b904bf4ab9..c89d53a6e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/eoseManagers/PerUserEoseManager.kt @@ -77,7 +77,7 @@ abstract class PerUserEoseManager( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt index 92b40d456e..c7e146b5bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyCoordinator.kt @@ -78,7 +78,7 @@ class NotifyCoordinator( } } - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 08ec932e0e..dcda63f127 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler( newEose(TimeUtils.now(), relay, forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt index 4d3ed060cd..f466cb4f17 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/FilterFindFollowMetadataForKey.kt @@ -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, @@ -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, - indexRelays: Set, - homeRelays: Set, - searchRelays: Set, - connected: Set, - commonRelays: Set, - cannotConnectRelays: Set, - hasTried: EOSEAccountFast, -): Map> = - 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) - } - } - } - } - } - } - } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt index 485f8fb9f9..5f15938130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/AccountNotificationsHistoryEoseManager.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt index 384c89df06..97098d765e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip47WalletConnect/NwcNotificationsEoseManager.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt index 02d351be88..911e6fb132 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip59GiftWraps/AccountGiftWrapsHistoryEoseManager.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt index c14d14bd19..f06d9a28d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/nip28PublicChats/ChannelLoaderSubAssembler.kt @@ -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, -) : SingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { +) : AccountScopedSingleSubNoEoseCacheEoseManager(client, allKeys, invalidateAfterEose = true) { override fun updateFilter(keys: List): List = filterMissingChannelsById(keys) override fun distinct(key: ChannelFinderQueryState) = key.channel diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt similarity index 61% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt index 005f4f203e..b3a9458bab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssemblerSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderShims.kt @@ -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, +) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt index b179e5a042..51e4ae0e67 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentWatcherSubAssembler.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt new file mode 100644 index 0000000000..c9d01b3b68 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderShims.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt index a527046727..df31d45479 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByAddress.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt index 0a56940449..1bab42b2ef 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/FilterByEvent.kt @@ -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) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt index 66718a9ea1..32942920eb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/speedLogger/RelaySpeedLogger.kt @@ -42,7 +42,7 @@ class RelaySpeedLogger( private val clientListener = object : RelayConnectionListener { - override fun onIncomingMessage( + override suspend fun onIncomingMessage( relay: IRelayClient, msgStr: String, msg: Message, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt index 403fcaf39f..f6032b8800 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relays/EOSE.kt @@ -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 = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast open class EOSEByKey( cacheSize: Int = 200, @@ -113,60 +114,3 @@ open class EOSEAccountKey( time: Long, ) = addOrUpdate(user, listCode, relayUrl, time) } - -class EOSEAccountFast( - cacheSize: Int = 20, -) { - private val users: LruCache = 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) { - 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? = - synchronized(lock) { - users[key]?.relayList?.keys?.toSet() - } - - fun newEose( - user: T, - relayUrl: NormalizedRelayUrl, - time: Long, - ) = addOrUpdate(user, relayUrl, time) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt index a8ba773138..5abc1998e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/RelayUsageListener.kt @@ -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() + + /** + * 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>() + + /** + * 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() + + /** + * 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() + + /** + * 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() + + /** Notice texts already logged, so one wording costs one line however often it arrives. */ + private val loggedNotices = ConcurrentHashMap.newKeySet() + 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 } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt index eaa90e8d10..3c4cde0ccf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageAccountant.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt index 73c4f510f3..4387cb2b40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageReportAssembler.kt @@ -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>, + budget: Int, + ): Set { + val included = mutableSetOf() + 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)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt index 7e7824631b..fc63089343 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/ResourceUsageStore.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt index e43cde73a9..fd222cf993 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/resourceusage/UsageKeys.kt @@ -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 `.[.]` 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 { + 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 `..` 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>() + private val VERB_DOWN_KEYS = ConcurrentHashMap>() + + /** + * `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>() + + /** The five `relay.purpose..*` keys, indexed by the `P_` constants above. */ + private fun purposeKeys(purpose: String): Array = 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.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 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt new file mode 100644 index 0000000000..ed4aba59d0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/FileAttachmentCard.kt @@ -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 } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt index 02592b6a06..9a96186328 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ShareHelper.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 3e560530cc..1122b73a57 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt index c352077e42..0b975ede46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/pdf/PdfPreviewCard.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 9e5249d0c9..7a59ab20f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -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 { MessagesSettingsScreen(accountViewModel, nav) } composableFromEnd { AudioVisualizerSettingsScreen(accountViewModel, nav) } composableFromEnd { BottomBarSettingsScreen(accountViewModel, nav) } + composableFromEnd { DrawerSettingsScreen(accountViewModel, nav) } composableFromEnd { HomeTabsSettingsScreen(accountViewModel, nav) } composableFromEnd { ProfileUiSettingsScreen(accountViewModel, nav) } composableFromEnd { VideoPlayerSettingsScreen(accountViewModel, nav) } @@ -747,6 +762,14 @@ fun BuildNavigation( ) } + composableFromEndArgs { + ConcordInviteLinksScreen( + communityId = it.communityId, + accountViewModel = accountViewModel, + nav = nav, + ) + } + composableFromEndArgs { ConcordEditScreen( communityId = it.communityId, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt index 1457927832..1fc4a00534 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/KeyboardState.kt @@ -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 { } } } - -/** - * 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) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 51f545d95c..5959cb5c25 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -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): Set = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] } + +/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */ +fun Set.toNames(): List = map { it.name }.sorted() + data class NavBarItemDef( val id: NavBarItem, val labelRes: Int, @@ -443,34 +455,6 @@ val DefaultBottomBarItems: List = /** The default bottom bar as unified entries (all built-in; favorites are added by the user). */ val DefaultBottomBarEntries: List = 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 = - listOf( - NavBarItem.HOME, - NavBarItem.MESSAGES, - NavBarItem.VIDEO, - NavBarItem.BROWSER, - NavBarItem.DISCOVER, - NavBarItem.NOTIFICATIONS, - ) - -val DrawerYouItems: List = - 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 = */ data class NavBarCategory( val titleRes: Int, + val icon: MaterialSymbol, val items: List, ) @@ -491,6 +476,7 @@ val BottomBarCategories: List = listOf( NavBarCategory( R.string.bottom_bar_category_main, + MaterialSymbols.Home, listOf( NavBarItem.HOME, NavBarItem.MESSAGES, @@ -501,6 +487,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_chats, + MaterialSymbols.Group, listOf( NavBarItem.PUBLIC_CHATS, NavBarItem.RELAY_GROUPS, @@ -510,6 +497,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_you, + MaterialSymbols.AccountCircle, listOf( NavBarItem.PROFILE, NavBarItem.MY_LISTS, @@ -527,6 +515,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_feeds, + MaterialSymbols.Subscriptions, listOf( NavBarItem.ARTICLES, NavBarItem.LONGS, @@ -553,6 +542,7 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_apps, + MaterialSymbols.Apps, listOf( NavBarItem.BROWSER, NavBarItem.FAVORITE_APPS, @@ -563,43 +553,9 @@ val BottomBarCategories: List = ), NavBarCategory( R.string.bottom_bar_category_other, + MaterialSymbols.Settings, listOf( NavBarItem.SETTINGS, ), ), ) - -val DrawerFeedsItems: List = - 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, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index f0221e563d..6009583b38 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -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, + section: DrawerSection, + hidden: Set, 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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt new file mode 100644 index 0000000000..5c649a9a60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerItemVisibility.kt @@ -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 = 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, + 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, + item: NavBarItem, + ): Set = + 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): Set = hidden - MandatoryDrawerItems + + /** The rows of [section] to render, in the section's fixed order. */ + fun visibleItems( + section: DrawerSection, + hidden: Set, + ): List = 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, + ): 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, + section: DrawerSection, + ): Set = hidden + section.items.filter { it !in MandatoryDrawerItems } + + /** Shows every row of [section] again. */ + fun showAll( + hidden: Set, + section: DrawerSection, + ): Set = hidden - section.items.toSet() + + /** Total hidden rows across every section — the count the settings screen shows at the top. */ + fun totalHidden(hidden: Set): Int = DrawerSections.sumOf { hiddenCount(it, hidden) } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt new file mode 100644 index 0000000000..2cf323c39b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerSections.kt @@ -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, + /** + * 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 = + listOf( + NavBarItem.HOME, + NavBarItem.MESSAGES, + NavBarItem.VIDEO, + NavBarItem.BROWSER, + NavBarItem.DISCOVER, + NavBarItem.NOTIFICATIONS, + ) + +private val DrawerYouItems: List = + 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 = + 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 = + 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 = setOf(NavBarItem.FAVORITE_APPS) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt new file mode 100644 index 0000000000..c36e3bc30b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/ImeSettler.kt @@ -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 } + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index 1526d0be72..ac0adde7bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -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/ 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, ) { navigationScope.launch { + ime.settle() controller.navigate(route) { popUpTo(klass) { inclusive = true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt index f6c42c0aa3..8b9a2d0e40 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/RememberNavs.kt @@ -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) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 5d18946dff..b5bb51626e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt new file mode 100644 index 0000000000..09e1ab078c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/CopyNoteText.kt @@ -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(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, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt index a1161ab2b5..0363421c51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteQuickActionMenu.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt index df3f8c5572..ba13c44b22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/elements/NoteActionSections.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt index 5bbf7bfdfa..03c78556de 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/share/ShareNoteAsQrScreen.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt index 62d91d39aa..96730249ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/FileHeader.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt index 059fa68747..8e999414f7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/SoftwareApp.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index b9954841e1..02347f4c91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt index d00bd6d2e8..1d8cdcc3d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Video.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt index 0abc16ac93..4f5c661810 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VideoDisplay.kt @@ -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) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 7963b56c27..3825dd0f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -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> = account.settings.syncedSettings.navigation.bottomBarItems + fun hiddenDrawerItemsFlow(): StateFlow> = account.settings.syncedSettings.navigation.hiddenDrawerItems + + /** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */ + fun changeHiddenDrawerItems(items: Set) { + if (account.applyHiddenDrawerItems(items)) { + launchSigner { account.sendNewAppSpecificData() } + } + } + fun changeBottomBarItems(items: List) { // 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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt index 35f9774204..77b637efdb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/bookmarkgroups/display/BookmarkGroupItemOptions.kt @@ -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 { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt index 2e615a33d2..82df6800dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/EmbeddedWebAppController.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt index 98d19c7937..2433459063 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/AgentConsoleViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index a619471e3f..dccf0cb851 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt new file mode 100644 index 0000000000..9d419d535b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzJoinReconnect.kt @@ -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) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt index 9cded7263f..a3976d203f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/buzz/BuzzRelayImportViewModel.kt @@ -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. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt index 4cbe77bffb..c9e30794a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/datasource/ChatroomNip04HistorySubAssembler.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index ce87e19328..d87f54987d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt index 390d718d9b..fc64efe0ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/PrivateMessageEditFieldRow.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt index 1b40e49820..57dffc1084 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordChannelListScreen.kt @@ -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(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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt new file mode 100644 index 0000000000..ef10b8c2bb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteLinksScreen.kt @@ -0,0 +1,273 @@ +/* + * 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.chats.publicChannels.concord + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.ui.components.util.setText +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry +import kotlinx.coroutines.launch +import java.text.DateFormat +import java.util.Date +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon as SymbolIcon + +/** What the screen is currently showing. The unreadable case is deliberately not "empty" — see below. */ +private sealed interface LinksState { + data object Loading : LinksState + + data class Loaded( + val links: List, + ) : LinksState + + /** + * The kind-13303 list could not be read. Distinct from an empty list on purpose: rendering + * "no links yet" here would tell a creator that the link they came to kill does not exist. + */ + data object Unreadable : LinksState +} + +/** + * Every invite link this account minted for one community, with the ability to retire one + * (CORD-05 §2). + * + * The list is the creator's own kind-13303 Invite List, which is where a link's `signer_sk` lives — + * so this shows only links *this account* minted, from any of its devices. Another admin's links are + * invisible here and un-revokable from here, because the secret that authors their coordinate was + * never ours. That is a property of the protocol, not a gap in the screen. + * + * Fetched on entry rather than collected from a flow: nothing subscribes to kind 13303 (it is + * bookkeeping the user never sees), so there is no cache to observe. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ConcordInviteLinksScreen( + communityId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val scope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + + var state by remember(communityId) { mutableStateOf(LinksState.Loading) } + var reloads by remember(communityId) { mutableIntStateOf(0) } + var confirming by remember { mutableStateOf(null) } + var revoking by remember { mutableStateOf(false) } + + LaunchedEffect(communityId, reloads) { + state = LinksState.Loading + state = account.concord.listConcordInviteLinks(communityId)?.let { LinksState.Loaded(it) } ?: LinksState.Unreadable + } + + val communityName = + remember(account, communityId) { + account.concordChannelList.liveCommunities.value + .firstOrNull { it.id == communityId } + ?.name + .orEmpty() + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text(stringRes(R.string.concord_invite_links_title), fontWeight = FontWeight.Bold) + if (communityName.isNotBlank()) { + Text(communityName, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + SymbolIcon(symbol = MaterialSymbols.AutoMirrored.ArrowBack, contentDescription = stringRes(R.string.back)) + } + }, + ) + }, + ) { padding -> + when (val current = state) { + is LinksState.Loading -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + is LinksState.Unreadable -> CenteredMessage(padding, stringRes(R.string.concord_invite_links_unreadable)) + + is LinksState.Loaded -> + if (current.links.isEmpty()) { + CenteredMessage(padding, stringRes(R.string.concord_invite_links_empty)) + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(current.links, key = { it.token }) { link -> + InviteLinkRow( + link = link, + enabled = !revoking, + onCopy = { scope.launch { clipboard.setText(link.url) } }, + onRevoke = { confirming = link }, + ) + HorizontalDivider() + } + } + } + } + } + + confirming?.let { link -> + AlertDialog( + onDismissRequest = { if (!revoking) confirming = null }, + title = { Text(stringRes(R.string.concord_invite_revoke_title)) }, + text = { Text(stringRes(R.string.concord_invite_revoke_explainer)) }, + confirmButton = { + TextButton( + enabled = !revoking, + onClick = { + revoking = true + scope.launch { + try { + val ok = account.concord.revokeConcordInvite(communityId, link.token) + accountViewModel.toastManager.toast( + R.string.concord_invite_links_title, + if (ok) R.string.concord_invite_revoked_ok else R.string.concord_invite_revoked_failed, + ) + // Re-read either way: on success the link is gone from the list, and on + // failure the list is the only thing that can say whether it changed. + reloads++ + } finally { + revoking = false + confirming = null + } + } + }, + ) { + Text(stringRes(R.string.concord_invite_revoke_confirm), color = MaterialTheme.colorScheme.error) + } + }, + dismissButton = { + TextButton(enabled = !revoking, onClick = { confirming = null }) { + Text(stringRes(R.string.cancel)) + } + }, + ) + } +} + +@Composable +private fun CenteredMessage( + padding: PaddingValues, + message: String, +) { + Box(Modifier.fillMaxSize().padding(padding).padding(24.dp), contentAlignment = Alignment.Center) { + Text( + message, + // This Box sits on the bare window background, so LocalContentColor is still the M3 + // default black — see the sibling invite screen, where that made the text invisible. + color = MaterialTheme.colorScheme.onBackground, + style = MaterialTheme.typography.bodyLarge, + ) + } +} + +@Composable +private fun InviteLinkRow( + link: ConcordInviteListEntry, + enabled: Boolean, + onCopy: () -> Unit, + onRevoke: () -> Unit, +) { + var menuOpen by remember { mutableStateOf(false) } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(Modifier.weight(1f).padding(end = 8.dp)) { + // The token prefix is what tells two links to the same community apart; their URLs share + // a long prefix, so they are useless as labels until well past where the row wraps. + Text(link.token.take(8), fontWeight = FontWeight.Bold, style = MaterialTheme.typography.bodyLarge) + Text( + stringRes(R.string.concord_invite_links_created, DateFormat.getDateInstance(DateFormat.MEDIUM).format(Date(link.createdAt * 1000))), + style = MaterialTheme.typography.bodySmall, + ) + Text(link.url, style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + + IconButton(enabled = enabled, onClick = { menuOpen = true }) { + SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(R.string.more_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringRes(R.string.copy_to_clipboard)) }, + onClick = { + menuOpen = false + onCopy() + }, + ) + DropdownMenuItem( + text = { Text(stringRes(R.string.concord_invite_revoke_action), color = MaterialTheme.colorScheme.error) }, + onClick = { + menuOpen = false + onRevoke() + }, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt index 2788dee7ee..7b19436713 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordInviteScreen.kt @@ -124,6 +124,8 @@ fun ConcordInviteScreen( RedeemState.Failed(R.string.concord_invite_failed_incompatible, canRetry = false) is ConcordInviteResult.Revoked -> RedeemState.Failed(R.string.concord_invite_failed_revoked, canRetry = false) + is ConcordInviteResult.Banned -> + RedeemState.Failed(R.string.concord_invite_failed_banned, canRetry = false) is ConcordInviteResult.Expired -> RedeemState.Failed(R.string.concord_invite_failed_expired, canRetry = false) is ConcordInviteResult.NotReachable -> @@ -161,6 +163,10 @@ fun ConcordInviteScreen( Text( stringRes(R.string.concord_redeeming_invite), modifier = Modifier.padding(top = 16.dp), + // Explicit: this Column sits on the bare window background with no Surface + // above it, so LocalContentColor is still the M3 default black — which renders + // every one of these labels invisible in the dark theme. + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) } @@ -170,6 +176,7 @@ fun ConcordInviteScreen( Text( stringRes(failed.messageRes), style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onBackground, textAlign = TextAlign.Center, ) if (failed.canRetry) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt index 57b146653b..a9e166c46c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMembersScreen.kt @@ -133,7 +133,10 @@ fun ConcordMembersScreen( } val iAmOwner = state?.authority?.isOwner(myPubKey) == true - val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.effectivePermissions(myPubKey).has(ConcordPermissions.BAN) } == true + // hasPermission, never effectivePermissions: a banned BAN-holder used to keep the whole Ban / + // Remove menu. It only stayed harmless because `canBanTarget` below routes through canActOn, + // which IS ban-aware — a thin margin for the escalation in docs/concord-soft-ban-audit.md. + val iCanBan = state?.let { it.authority.isOwner(myPubKey) || it.authority.hasPermission(myPubKey, ConcordPermissions.BAN) } == true val iCanManageRoles = state?.authority?.hasPermission(myPubKey, ConcordPermissions.MANAGE_ROLES) == true // The roles this viewer may actually hand out. The fold drops a grant whose granter does diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt index 2720cb1d77..a6cb65f165 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/concord/datasource/ConcordChannelHistoryFilterAssembler.kt @@ -170,7 +170,7 @@ class ConcordChannelHistorySubAssembler( // cursors so a late callback can't move another channel's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt index 7b6f7bbcac..f75b814bf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenChatHistoryFilterAssembler.kt @@ -126,7 +126,7 @@ class RelayGroupOpenChatHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt index ddbef10a9a..cb2d8cc4b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/datasource/RelayGroupOpenThreadsHistoryFilterAssembler.kt @@ -123,7 +123,7 @@ class RelayGroupOpenThreadsHistorySubAssembler( // cursors so a late callback can't move another group's cursors. newEose runs regardless. val myCursors = cursorsFor(key) return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt index f14cd1d59e..8176dc4d5b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/EditFieldRow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.send +import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -53,7 +54,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.INav import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -78,7 +78,7 @@ fun EditFieldRow( onSendNewMessage: suspend () -> Unit, nav: INav, ) { - KeyboardAwareBackHandler { + BackHandler { accountViewModel.launchSigner { channelScreenModel.sendDraftSync() channelScreenModel.cancel() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt index bf7f2fee05..60a41ede35 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListNip04HistorySubAssembler.kt @@ -108,7 +108,7 @@ class ChatroomListNip04HistorySubAssembler( // cursors so a late callback can't move another account's cursors. newEose runs regardless. val myCursors = key.account.chatroomList.nip04History return object : SubscriptionListener { - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 01616b65a6..0780530666 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -70,7 +70,7 @@ class ChessFeedFilterSubAssembler( newEose(key, relay, TimeUtils.now(), forFilters) } - override fun onEvent( + override suspend fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt index d0b55dc108..bb2673f7bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedImeBridge.kt @@ -56,6 +56,53 @@ interface EmbeddedImeBridge { fun sendImeOp(json: String) } +/** + * Asks the page to re-announce its focused field (as an [ImeEvent.ReFocus]). A warm tab keeps the page's DOM + * focus while it sits off-screen, so no `focusin` ever fires for it again and this is the only way the host + * learns there is a field to put the keyboard back on. Sent when a tab becomes the active one again, and when + * an [ImeEvent.WantKeyboard] tap arrives for a field this host no longer mirrors. + */ +fun EmbeddedImeBridge.requestImeResync() = sendImeOp(JSONObject().put("type", "ime.resync").toString()) + +/** Parses one page → host `ime.*` envelope into an [ImeEvent], or null for anything unrecognized. */ +fun parseImeEvent(payload: String): ImeEvent? { + val o = runCatching { JSONObject(payload) }.getOrNull() ?: return null + return when (o.optString("type")) { + "ime.focus" -> parseFocus(o) + "ime.wantkb" -> ImeEvent.WantKeyboard + "ime.refocus" -> ImeEvent.ReFocus(parseFocus(o)) + "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 fun parseFocus(o: JSONObject) = + ImeEvent.Focus( + inputType = o.optString("inputType", "text"), + enterKeyHint = o.optString("enterKeyHint", ""), + multiline = o.optBoolean("multiline", false), + readOnly = o.optBoolean("readOnly", false), + text = o.optString("text", ""), + selStart = o.optInt("selStart", 0), + selEnd = o.optInt("selEnd", 0), + geometry = parseSelectionGeometry(o.optJSONObject("geom")), + ) + /** What the focused page field reports up to the host keyboard. */ sealed interface ImeEvent { /** A field took focus; carries enough to configure the keyboard and seed the editing buffer. */ @@ -63,12 +110,40 @@ sealed interface ImeEvent { val inputType: String, val enterKeyHint: String, val multiline: Boolean, + /** + * The field is `readonly`: focusable and selectable, but not typeable. Native Chrome focuses such a + * field without raising the keyboard, so the host must not either — otherwise the user gets a keyboard + * whose keystrokes the page discards. + */ + val readOnly: Boolean = false, val text: String, val selStart: Int, val selEnd: Int, val geometry: SelectionGeometry? = null, ) : ImeEvent + /** + * The user tapped inside a field that is **already** focused in the page: put the keyboard back up. No + * focus event follows a tap on a field that never blurred, so this is the only signal that the user wants + * the keyboard back after dismissing it — or after a tab switch released the host's mirror. + * + * Deliberately payload-free (it fires on every tap in a field): a host that no longer mirrors the field + * answers it with an [requestImeResync] and takes the state from the [ReFocus] that comes back. + */ + data object WantKeyboard : ImeEvent + + /** + * The page's answer to [requestImeResync]: the editing state of a field that is already focused there. + * Distinct from [Focus] because page focus never changed — the host must not restart the input on a field + * it is already mirroring (that would kill a live composing region mid-word). + * + * Carries no geometry: measuring a caret rect forces a synchronous layout in the page, and the host has no + * use for it here (the `ime.carettap` that rides along with a tap carries fresh geometry). + */ + data class ReFocus( + val focus: Focus, + ) : ImeEvent + /** The field lost focus — dismiss the keyboard. */ data object Blur : ImeEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index 7246b19577..9f4bf544d5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -149,9 +149,27 @@ object EmbeddedTabHost { contentBounds = bounds } + // Tabs whose soft keyboard was up at the moment the user left them. A warm tab keeps its page focus while + // it sits off-screen, so coming back is a *resume*: the keyboard returns only for the tabs that had one, + // the way Android restores a window's IME state. A tab the user deliberately typed on and then dismissed + // the keyboard for comes back with the caret still in the field but the page unobstructed. + private val keyboardUpOnLeave = mutableSetOf() + + /** Records whether the soft keyboard was up as [id] stops being the active tab. */ + fun noteKeyboardOnLeave( + id: String, + wasUp: Boolean, + ) { + if (wasUp) keyboardUpOnLeave.add(id) else keyboardUpOnLeave.remove(id) + } + + /** Consumes the "restore the keyboard" mark for [id] (a restore happens at most once per leave). */ + fun takeKeyboardRestore(id: String): Boolean = keyboardUpOnLeave.remove(id) + fun evict(id: String) { val w = warm.firstOrNull { it.id == id } ?: return if (activeId == id) activeId = null + keyboardUpOnLeave.remove(id) warm.remove(w) w.controller.teardown() } @@ -165,6 +183,7 @@ object EmbeddedTabHost { fun evictAll() { activeId = null + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } @@ -177,6 +196,8 @@ object EmbeddedTabHost { * its screen re-acquires — the user just sees the current tab reload, not a blanked-out surface. */ fun rebuildAll() { + // Every page is about to be rebuilt from scratch, so no field survives to restore a keyboard onto. + keyboardUpOnLeave.clear() val copy = warm.toList() warm.clear() copy.forEach { it.controller.teardown() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 77021fa742..73472ede01 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -348,7 +348,22 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // the selection). All the show/hide state lives in one [SelectionUiState] so the rules — toolbar hides // while dragging or scrolling, handles hide while scrolling — are expressed in one place. val sel = remember { SelectionUiState() } + // The keyboard collapsing while this view still mirrors the page field means the user put it away on a + // field they are still on (BACK, or the IME's own hide button) — record it so returning to this tab + // doesn't pop the keyboard back over the page. A tab switch also collapses the insets but does NOT + // look like this: measured on device, the switch takes focus off the view in the same frame, so + // isMirroringPageField() is already false there and the mark this tab was owed survives. + LaunchedEffect(activeId, imeBottomPx) { + if (imeBottomPx == 0 && imeView.isMirroringPageField()) imeView.noteKeyboardDismissed() + } DisposableEffect(imeBridge) { + val boundId = activeId + // A warm tab keeps its page focus while parked, so returning to it fires no focus event: ask the + // page to re-announce whatever field is still focused. Restores the mirror (so a tap can raise the + // keyboard) and, when the user left mid-typing, brings the keyboard straight back. + // Consumed only when there is a bridge to ask (the mark is one-shot, so spending it on a bind that + // can't send the resync would drop the restore this tab was owed). + var pendingRestore = imeBridge != null && boundId != null && EmbeddedTabHost.takeKeyboardRestore(boundId) imeView.bind(imeBridge) imeView.onRangeSelectionChanged = { sel.onFieldRangeToggle(it) } imeView.onEdited = { sel.onEdited() } @@ -362,8 +377,31 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // Cancel any in-flight scroll-hide from the page phase: otherwise the field's own // selection-reveal scrolls keep it armed and the new field handles/toolbar never appear. sel.scrolling = false + sel.onFieldReadOnly(event.readOnly) sel.onFieldGeometry(event.geometry, event.text.isNotEmpty()) } + ImeEvent.WantKeyboard -> { + // Still mirroring this field (the keyboard was merely dismissed) → just put it back. + // Otherwise the mirror was released by a tab switch and holds another tab's buffer: + // ask for the state first and raise once it lands. + if (imeView.isMirroringPageField()) { + imeView.raiseKeyboard() + } else { + pendingRestore = true + imeBridge.requestImeResync() + } + } + is ImeEvent.ReFocus -> { + // Raise only if something asked for it — a tap that rang the doorbell above, or a tab + // left with the keyboard up. A plain tab return re-takes the field silently. + imeView.onPageReFocus(event.focus, pendingRestore) + pendingRestore = false + // Re-arm "the field has text" so a tap can show the insertion handle again (a tab + // switch resets it). Geometry stays null here, so this can't pop a handle up on its + // own — native shows nothing until the user touches the field. + sel.onFieldReadOnly(event.focus.readOnly) + sel.onFieldGeometry(null, event.focus.text.isNotEmpty()) + } ImeEvent.Blur -> { imeView.onPageBlur() sel.onBlur() @@ -377,11 +415,29 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { is ImeEvent.CaretTap -> sel.onCaretTap(event.geometry) } } + // Posted, not sent inline: the session's own `active` effect resumes a paused (parked) WebView in + // this same effect pass, and a message delivered to a still-paused page can be lost. One turn of + // the main looper later, the resume is already on its way over the same (FIFO) channel. + imeView.post { imeBridge?.requestImeResync() } onDispose { imeBridge?.onImeEvent = null imeView.onRangeSelectionChanged = null imeView.onEdited = null sel.reset() + // Remember whether the keyboard was up BEFORE onPageBlur takes it down, so returning to this + // tab resumes exactly the state it was left in. The page keeps its focus (and caret) either + // way: blurring it here would fire the page's own blur handlers — validation, autocomplete + // dismissal, submit-on-blur — for a switch the user never made inside the page. + // + // Ask the mirror what it *intends* rather than sampling the window: by the time this dispose + // runs, the nav transition has already snapped `WindowInsets.imeAnimationTarget` to 0 and + // taken focus off the view, so both would report "no keyboard" for every tab the user left + // mid-typing — which is exactly the case this restore exists for. [wantsKeyboardForPageField] + // also answers the other half: only a keyboard THIS mirror holds counts, so typing in the + // browser's own address bar never arms a restore for a page field. + if (boundId != null) { + EmbeddedTabHost.noteKeyboardOnLeave(boundId, imeView.wantsKeyboardForPageField()) + } imeView.onPageBlur() imeView.bind(null) } @@ -475,25 +531,41 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { // In-field (/ +

+
hello world
+
+
+
+
+
+
+
+
+ + + + +
+ +
+ + diff --git a/tools/ime-test/perf.html b/tools/ime-test/perf.html new file mode 100644 index 0000000000..b1efbb4d78 --- /dev/null +++ b/tools/ime-test/perf.html @@ -0,0 +1,206 @@ + + + + + +Embed vs direct — runtime perf probe + + + +

Runtime perf probe

+

Open this same URL twice — once as an embedded tab, once in the full-screen browser — and compare. Both are the same WebView in the same process; any gap is host-induced.

+ + + +
+ + + + + + + + diff --git a/tools/ime-test/shim-events.mjs b/tools/ime-test/shim-events.mjs new file mode 100644 index 0000000000..14882505a7 --- /dev/null +++ b/tools/ime-test/shim-events.mjs @@ -0,0 +1,177 @@ +// Regression test for the embedded-WebView IME relay's page→host protocol. +// +// Loads the REAL shim (commons/src/commonMain/composeResources/files/napplet/shim.js) into real Chromium +// with the embedded-surface flags set, drives genuine focus/tap/blur gestures, and asserts the `ime.*` +// envelopes it emits. This is the only honest automated coverage for this code: the host-side parser runs +// on Android's `org.json`, which the JVM unit tests stub out (`unitTests.isReturnDefaultValues = true`), so +// a Kotlin test of it would pass without parsing anything. +// +// cd tools/ime-test && npm i playwright-core && node shim-events.mjs +// +// Exits 0 if every expectation holds, 1 otherwise. Override the browser with CHROMIUM_PATH, and the shim +// under test with argv[2] (useful for diffing a candidate against the committed one). + +import { chromium } from 'playwright-core' +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { dirname, resolve } from 'node:path' + +const HERE = dirname(fileURLToPath(import.meta.url)) +const SHIM = process.argv[2] ?? resolve(HERE, '../../commons/src/commonMain/composeResources/files/napplet/shim.js') +const CHROMIUM = process.env.CHROMIUM_PATH ?? '/opt/pw-browsers/chromium-1194/chrome-linux/chrome' + +const HTML = `ime + +

plain page text, not editable

+ + +
+ editable span text +
+` + +const browser = await chromium.launch({ executablePath: CHROMIUM, args: ['--no-sandbox'] }) +const context = await browser.newContext() + +// Stand in for the native bridge the `:napplet` process installs: collect what the page sends, and keep the +// reply channel so host→page ops (`ime.resync`) can be delivered exactly as the host delivers them. +await context.addInitScript(() => { + window.__sent = [] + window.__nappletDirectBridge = true + window.__nappletImeProxy = true + window.__nappletBridge = { + postMessage(s) { window.__sent.push(s) }, + set onmessage(fn) { window.__imeIn = fn }, + get onmessage() { return window.__imeIn }, + } +}) +await context.addInitScript({ content: readFileSync(SHIM, 'utf8') }) +await context.route('https://ime.test/**', (route) => route.fulfill({ contentType: 'text/html', body: HTML })) +const page = await context.newPage() +await page.goto('https://ime.test/') + +const drain = async () => { + const raw = await page.evaluate(() => { const s = window.__sent.slice(); window.__sent.length = 0; return s }) + return raw.map((s) => JSON.parse(s)).filter((m) => (m.type || '').startsWith('ime.')) +} + +const failures = [] +const results = [] + +// `expect` is a predicate over the messages one gesture produced, described in words for the report. +const step = async (name, gesture, expectation) => { + await gesture() + await page.waitForTimeout(150) + const msgs = await drain() + const problem = expectation(msgs) + const types = msgs.map((m) => m.type).join(', ') || '(nothing)' + results.push([name, types, problem]) + if (problem) failures.push(`${name}: ${problem}\n got: ${types}`) +} + +const has = (msgs, type) => msgs.some((m) => m.type === type) +const find = (msgs, type) => msgs.find((m) => m.type === type) + +await step( + 'tap an unfocused field announces it and asks for the keyboard', + () => page.click('#inp'), + (m) => { + const focus = find(m, 'ime.focus') + if (!focus) return 'no ime.focus' + if (focus.text !== 'hello world') return `ime.focus carried text "${focus.text}"` + if (focus.readOnly !== false) return 'ime.focus said readOnly on an editable field' + return has(m, 'ime.wantkb') ? null : 'no ime.wantkb doorbell' + }, +) + +// The regression this suite exists for: a tap on a field that never blurred fires no focus event, so before +// `ime.wantkb` existed the host had no signal at all and the keyboard could not be brought back. +await step( + 'tap the SAME already-focused field still rings the doorbell', + () => page.click('#inp'), + (m) => { + if (!has(m, 'ime.wantkb')) return 'no ime.wantkb — the keyboard could never come back' + if (has(m, 'ime.focus')) return 'unexpected ime.focus (page focus never moved)' + return null + }, +) + +// The doorbell must stay payload-free: it fires on every tap in a field, so attaching the editing state +// would put the whole field text on the wire per tap. +await step( + 'the doorbell carries no payload', + () => page.click('#inp'), + (m) => { + const kb = find(m, 'ime.wantkb') + if (!kb) return 'no ime.wantkb' + const extra = Object.keys(kb).filter((k) => k !== 'type' && k !== 'id') + return extra.length ? `ime.wantkb carried ${extra.join(', ')}` : null + }, +) + +await step( + 'a host resync is answered with the focused field state', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => { + const re = find(m, 'ime.refocus') + if (!re) return 'no ime.refocus' + if (re.text !== 'hello world') return `ime.refocus carried text "${re.text}"` + if (re.geom !== undefined) return 'ime.refocus carried geometry (forces a synchronous layout per send)' + return null + }, +) + +await step( + 'tapping off the field blurs it', + () => page.click('#para'), + (m) => (has(m, 'ime.blur') ? null : 'no ime.blur'), +) + +await step( + 'a resync with nothing focused answers nothing', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => (m.length ? 'answered a resync with no focused field' : null), +) + +await step( + 'a readonly field announces itself as readonly', + () => page.click('#ro'), + (m) => { + const focus = find(m, 'ime.focus') + if (!focus) return 'no ime.focus' + return focus.readOnly === true ? null : 'ime.focus did not report readOnly' + }, +) + +await step( + 'readonly survives the resync round trip', + () => page.evaluate(() => window.__imeIn({ data: JSON.stringify({ type: 'ime.resync' }) })), + (m) => { + const re = find(m, 'ime.refocus') + if (!re) return 'no ime.refocus' + return re.readOnly === true ? null : 'ime.refocus dropped readOnly — a restore would raise a keyboard' + }, +) + +// contenteditable taps land on a child node, so the doorbell has to test containment, not equality. +await step( + 'a tap inside an already-focused contenteditable rings the doorbell', + async () => { + await page.click('#cespan') + await page.waitForTimeout(150) + await drain() + await page.click('#cespan') + }, + (m) => (has(m, 'ime.wantkb') ? null : 'no ime.wantkb from inside a contenteditable'), +) + +console.log(`\nshim: ${SHIM}\n`) +for (const [name, types, problem] of results) { + console.log(` ${problem ? 'FAIL' : 'ok '} ${name}\n ${types}`) +} +if (failures.length) { + console.log(`\n${failures.length} failure(s):\n`) + failures.forEach((f) => console.log(` - ${f}`)) +} +await browser.close() +process.exit(failures.length ? 1 : 0)