mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'upstream/main' into test/polls-results
This commit is contained in:
@@ -85,3 +85,30 @@ skills verified clean):
|
||||
`ParseReturn.entity` (the `Nip19Parser.Return.*` sealed class never existed);
|
||||
Event Store section corrected from "Android only" to commonMain/all platforms
|
||||
with the real `store.sqlite.EventStore` import and suspend generic `query<T>`.
|
||||
|
||||
## Phase 4 (2026-08): Store-implementer skills (external consumer request)
|
||||
|
||||
Three skills added at the request of an external Quartz consumer
|
||||
(vespa-eventstore — a server-side `IEventStore` on Vespa that asserts result
|
||||
parity against the SQLite store in CI). All three document the
|
||||
**store/relay-implementer's perspective**, which `quartz-integration` and
|
||||
`nostr-expert` (client-side) did not cover. Requirements doc: the skill-requests
|
||||
file reviewed 2026-08-04; the requester's items #4 (storage-lifecycle-nips) was
|
||||
folded into `event-store-semantics` per their own recommendation, and #5
|
||||
(relay-server/geode policies) was declined as not currently needed.
|
||||
|
||||
- **`event-store-semantics/`** — the `IEventStore`/SQLite-store behavioral
|
||||
contract as named rules (STORE-Fxx/Wxx/Dxx/Cxx/Sxx/Nxx) with a semantics
|
||||
changelog for pin-bump review. Written from `QueryBuilder`,
|
||||
`MergeQueryExecutor`, the seven `*Module.kt` files, and `IEventStore` KDoc.
|
||||
- **`nip85-trusted-assertions/`** — the NIP-85 model (10040/30382/30383/30384/
|
||||
30385), full tag vocabulary with value semantics, authorization conventions,
|
||||
worked JSON examples, stability notes.
|
||||
- **`searchable-events/`** — the `SearchableEvent` contract + maintenance
|
||||
mandate, with `references/searchable-kinds.md` holding the exhaustive
|
||||
kind → class → `indexableContent()` table (126 classes / 129 kinds) that
|
||||
external search engines diff at version bumps.
|
||||
|
||||
Follow-ups suggested but not implemented: a shared JSON test-vector corpus for
|
||||
filter semantics (testFixtures both the SQLite tests and external parity suites
|
||||
could run), and a snapshot test pinning the searchable-kind set.
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
---
|
||||
name: event-store-semantics
|
||||
description: The authoritative behavioral contract of Quartz's event stores — `IEventStore` and its reference SQLite implementation (`nip01Core/store/sqlite/`). Use when implementing or asserting parity with a Quartz event store (external engines like Vespa, the filesystem store, geode), answering filter-semantics questions (since/until inclusivity, tag OR/AND, multi-filter limits, ordering tiebreaks), or working on the write-path rules for replaceable/addressable supersession, NIP-09 deletions, NIP-40 expiration, NIP-62 vanish, NIP-45 counts, or NIP-50 search inside the store. Every behavior has a named rule id (STORE-Fxx/Wxx/Dxx/Sxx/Cxx) so downstream implementations can annotate divergences precisely.
|
||||
---
|
||||
|
||||
# Event Store Semantics — the `IEventStore` / SQLite-store contract
|
||||
|
||||
The SQLite `EventStore` (`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/`)
|
||||
is the de-facto **reference implementation** of what a Quartz event store must do. Other
|
||||
implementations — the in-repo filesystem store (`nip01Core/store/fs/`, held to parity by
|
||||
`quartz/src/jvmTest/.../store/fs/FsParityTest.kt`) and external engines (e.g. a Vespa-backed
|
||||
store) — reimplement its *observable behavior* and assert parity in CI. This skill states that
|
||||
behavior as **named, numbered decisions** so a parity divergence becomes a lookup, not an
|
||||
archaeology session through `QueryBuilder`/`MergeQueryExecutor`.
|
||||
|
||||
Every rule below was verified against the code as of this skill's last update. When you change
|
||||
store behavior, **update the rule here in the same PR** and add a line to the
|
||||
[Semantics changelog](#semantics-changelog) — downstream implementations pin Quartz by commit and
|
||||
review pin bumps against this file.
|
||||
|
||||
## Key files
|
||||
|
||||
| Concern | File |
|
||||
|---|---|
|
||||
| Public contract (KDoc is normative) | `nip01Core/store/IEventStore.kt` |
|
||||
| High-level store (owns pool + planner) | `sqlite/EventStore.kt`, `sqlite/SQLiteEventStore.kt` |
|
||||
| Filter → SQL, ordering, limits, counts | `sqlite/QueryBuilder.kt` |
|
||||
| k-way merge fast path (feed shapes) | `sqlite/MergeQueryExecutor.kt` |
|
||||
| Schema, tag hashing, immutability | `sqlite/EventIndexesModule.kt`, `sqlite/TagNameValueHasher.kt`, `sqlite/SeedModule.kt` |
|
||||
| Replaceable / addressable supersession | `sqlite/ReplaceableModule.kt`, `sqlite/AddressableModule.kt` |
|
||||
| NIP-09 / NIP-40 / NIP-62 / ephemeral | `sqlite/DeletionRequestModule.kt`, `sqlite/ExpirationModule.kt`, `sqlite/RightToVanishModule.kt`, `sqlite/EphemeralModule.kt` |
|
||||
| NIP-50 FTS | `sqlite/FullTextSearchModule.kt` (see also the `searchable-events` skill) |
|
||||
| Index/feature toggles | `sqlite/IndexingStrategy.kt` (client default) and geode's `RelayIndexingStrategy.kt` (relay preset) |
|
||||
| Operational README | `sqlite/README.md` (concurrency, pragmas, maintenance) |
|
||||
|
||||
Executable spec: the test suites in
|
||||
`quartz/src/commonTest/.../store/sqlite/` (`BasicTest`, `ReplaceableTest`, `AddressableTest`,
|
||||
`DeletionTest`, `ExpirationTest`, `RightToVanishTest`, `SearchTest`, `SearchRelevanceOrderTest`,
|
||||
`MergeQueryCorrectnessTest`, `TagMergeCorrectnessTest`, `QueryAssemblerTest`,
|
||||
`SnapshotIdsForNegentropyTest`, `FilterMatcherTest`, …). If a rule here ever contradicts a test,
|
||||
the test wins — and this file has a bug to fix.
|
||||
|
||||
## Kind classes (used throughout)
|
||||
|
||||
- **Replaceable**: kind `0`, kind `3`, and `10000 ≤ kind < 20000`.
|
||||
- **Ephemeral**: `20000 ≤ kind < 30000`.
|
||||
- **Addressable**: `30000 ≤ kind < 40000`.
|
||||
- Everything else is a regular event.
|
||||
|
||||
---
|
||||
|
||||
## Filter matching (STORE-F)
|
||||
|
||||
**STORE-F01 — `since`/`until` are both inclusive.** `since` compiles to
|
||||
`created_at >= ?`, `until` to `created_at <= ?` (`QueryBuilder` uses
|
||||
`greaterThanOrEquals`/`lessThanOrEquals` everywhere). An event with
|
||||
`created_at == since == until` matches.
|
||||
|
||||
**STORE-F02 — `ids` and `authors` are exact-match only.** They compile to `=`/`IN` against the
|
||||
full 64-char hex columns. **NIP-01 prefix matching is NOT supported** anywhere in the store.
|
||||
(`Filter`'s constructor logs an error for non-64-char ids/authors but still sends them; they
|
||||
simply never match.)
|
||||
|
||||
**STORE-F03 — tag filter combination.** Within one tag name, values are **OR**
|
||||
(`tag_hash IN (…)`). Across different tag names in the same filter, conditions are **AND**
|
||||
(each extra name becomes another `event_tags` self-join). `tagsAll` (NIP-91 `&x` syntax) demands
|
||||
**every listed value** be present on the event — one join + equality per value — and composes by
|
||||
AND with any plain `tags` in the same filter.
|
||||
|
||||
**STORE-F04 — only single-letter tag names are indexed (by default).**
|
||||
`DefaultIndexingStrategy.shouldIndex` indexes a tag iff `tag.size >= 2 && tag[0].length == 1`.
|
||||
A filter on a multi-letter tag name (`#title`, `#alt`) matches **nothing** in the SQLite store.
|
||||
Deployments can widen `shouldIndex`, but the stock contract is single-letter-only.
|
||||
|
||||
**STORE-F05 — `d` is special-cased out of the tag index.** `#d` values are matched against the
|
||||
`event_headers.d_tag` column, not `event_tags` (`Filter.toFilterWithDTags()`). Consequences:
|
||||
`#d` works on addressable events (which populate `d_tag`); when all `kinds` are addressable the
|
||||
query adds `kind >= 30000 AND kind < 40000` to pin the addressable index. **Only use `#d` via
|
||||
plain `tags`.** A `#d` under `tagsAll` is handled inconsistently: on the simple (no other
|
||||
tags/search) path it degrades to OR semantics (`toFilterWithDTags` folds it into `dTags`), and
|
||||
when `tags["d"]` is also present it is dropped entirely; on the tag-join path it is ignored.
|
||||
(An event has one d-tag, so AND-across-values could never match anyway.)
|
||||
|
||||
**STORE-F06 — tag and author matching in the tag path is hash-based.** `event_tags` stores a
|
||||
64-bit MurmurHash3 of `(tag name, value)` keyed by a per-database random seed (`SeedModule`,
|
||||
`TagNameValueHasher`); the p/e/a-owner columns are hashes too. There is **no post-verification**
|
||||
of hash matches, so a hash collision would return a false positive. Probability is negligible in
|
||||
practice but nonzero — a parity harness comparing against an exact-match engine should know this
|
||||
is the one place the reference can (theoretically) over-match.
|
||||
|
||||
**STORE-F07 — multiple filters are a union with dedup; `limit` is per-filter.** Each filter
|
||||
becomes its own row-id subquery with its **own** `ORDER BY … LIMIT`; branches are combined with
|
||||
SQL `UNION` (dedup by row). There is **no global limit** — a 3-filter query with limits
|
||||
10/20/30 can return up to 60 events, presented in one merged `created_at DESC` ordering. NIP-45
|
||||
counts and negentropy snapshots dedup the same way (`SELECT DISTINCT` / `UNION`).
|
||||
|
||||
**STORE-F08 — result ordering.** Non-search queries order `created_at DESC`. The `id ASC`
|
||||
tiebreak on equal `created_at` is applied **only when
|
||||
`IndexingStrategy.useAndIndexIdOnOrderBy = true`** — which is `false` in the client default
|
||||
**and** in geode's relay preset. So by default, same-second ordering is unspecified (SQLite
|
||||
returns them in storage order). Any newest-N is valid; a parity suite must not assert
|
||||
same-`created_at` order unless it configures the flag. One extra caveat with the flag ON: the
|
||||
`MergeQueryExecutor` tag-stream path still yields same-second ties in rowid order (its cursors
|
||||
run off `event_tags`, which has no id column) — a valid newest-N that may differ byte-for-byte
|
||||
from the single-SQL ordering.
|
||||
|
||||
**STORE-F09 — the merge fast path returns the same *set*.** Single-filter queries of the shape
|
||||
"authors (+kinds) + limit" or "one `#x` IN-list (+kinds) + limit" (≤2048 streams) route through
|
||||
`MergeQueryExecutor`, a k-way newest-first merge over per-(kind,author) / per-(tag-value,kind)
|
||||
index cursors with dedup by id on the tag shape. This is an optimization, not a semantics
|
||||
change — `MergeQueryCorrectnessTest`/`TagMergeCorrectnessTest` assert set-equality with the
|
||||
single-SQL plan (ordering caveat per STORE-F08).
|
||||
|
||||
**STORE-F10 — empty filter.** `query(Filter())` / `count(Filter())` match **everything**
|
||||
(`Filter.isEmpty()` → the "everything" query). `delete(Filter())` is deliberately asymmetric:
|
||||
it deletes **nothing** and returns 0, so a stray empty filter can't wipe the store (documented
|
||||
on `QueryBuilder.delete`).
|
||||
|
||||
**STORE-F11 — empty lists (`kinds = emptyList()` etc.) are a client error with inconsistent
|
||||
handling; don't rely on either outcome.** On the single-filter simple path an empty list
|
||||
renders as `1 = 0` → matches nothing. But `Filter.isEmpty()` treats empty lists the same as
|
||||
`null`, so on the multi-filter union path such a filter contributes no subquery — and a list of
|
||||
*only* empty-list filters degrades to the match-everything query. Known quirk; treat
|
||||
empty-list filters as invalid input rather than replicating this shape.
|
||||
|
||||
**STORE-F12 — `limit` edge cases.** `limit = 0` compiles to `LIMIT 0` → zero rows.
|
||||
`limit = null` means unbounded. Negative limits are not defended against (don't send them).
|
||||
|
||||
**STORE-F13 — the in-memory matcher is a separate (simpler) implementation.**
|
||||
`Filter.match(event)` (`FilterMatcher`) is used for live-stream matching, not storage queries;
|
||||
it checks ids/authors/kinds/tags/tagsAll/since/until but not `search` or `limit`. Parity work
|
||||
targets the SQL semantics above, not `FilterMatcher`.
|
||||
|
||||
---
|
||||
|
||||
## Write path (STORE-W)
|
||||
|
||||
Inserts run every module in one transaction: header+tags → NIP-09 side effects → expiration
|
||||
row → FTS row → vanish side effects. A trigger `RAISE(ABORT, …)` rejects the whole row with the
|
||||
messages quoted below (they surface as the NIP-01 `OK false` reason).
|
||||
|
||||
**STORE-W01 — replaceable supersession.** Unique index on `(kind, pubkey)` for replaceable
|
||||
kinds. A `BEFORE INSERT` trigger deletes any stored version that is *older* — meaning
|
||||
`created_at` smaller, **or equal `created_at` with lexicographically larger id** (NIP-01
|
||||
lowest-id-wins). Inserting a version that is *not* newer under that ordering leaves the stored
|
||||
row in place and fails the unique index → rejected (`UNIQUE constraint failed`). Net contract:
|
||||
exactly one version stored; newest wins; ties broken by lowest id; older re-inserts blocked.
|
||||
|
||||
**STORE-W02 — addressable supersession.** Same as W01 with unique index
|
||||
`(kind, pubkey, d_tag)` over `30000 ≤ kind < 40000`. Nuance: `d_tag` is populated from the
|
||||
*parsed* event class (`AddressableEvent.dTag()`); an addressable-range kind whose class doesn't
|
||||
parse as `AddressableEvent` stores `d_tag NULL`, and SQLite treats NULLs as distinct in unique
|
||||
indexes — such events don't supersede each other. An event with no `d` tag parses as `dTag() = ""`
|
||||
(empty string), which *does* dedupe normally.
|
||||
|
||||
**STORE-W03 — ephemeral events are never stored but are acked as accepted.**
|
||||
`insert()` returns silently and `batchInsert` reports `Accepted` for `20000 ≤ kind < 30000`
|
||||
without writing (the live relay stream still broadcasts them). A DB-level backstop trigger
|
||||
(`blocked: cannot store ephemeral events`) rejects any that sneak past the app-level check.
|
||||
|
||||
**STORE-W04 — expired events are rejected at insert.** App-level check
|
||||
(`event.isExpired()`) plus a trigger on the expiration-row insert
|
||||
(`blocked: this event is expired` when `expiration <= unixepoch()`). Single-event `insert`
|
||||
**throws**; `batchInsert` returns `Rejected`.
|
||||
|
||||
**STORE-W05 — expiry is enforced at insert and by sweep, NOT at query time.** Events with a
|
||||
future `expiration` store a row in `event_expirations`. Nothing filters them out of queries
|
||||
after the timestamp passes: **a query between expiry and the next `deleteExpiredEvents()` sweep
|
||||
returns the expired event.** Operators run the sweep periodically (README recommends ~15 min).
|
||||
Re-inserting an already-expired event after the sweep is rejected per W04.
|
||||
|
||||
**STORE-W06 — GiftWrap ownership is the recipient.** For kind 1059 the store computes
|
||||
`pubkey_owner_hash` from the `p`-tag recipient (falling back to the random signer key if
|
||||
absent). All owner-scoped machinery — NIP-09 re-insert blocking, NIP-62 vanish deletion and
|
||||
blocking — operates on that owner hash, so **a user's deletions/vanish remove giftwraps
|
||||
addressed to them**, even though the wrap's `pubkey` is a one-time key. (Consequently GiftWraps
|
||||
are also excluded from `authorsMissingOutbox()`.)
|
||||
|
||||
**STORE-W07 — immutability.** `event_headers`/`event_tags` rows are never updated
|
||||
(`BEFORE UPDATE` triggers abort). All supersession is delete + insert; `event_tags`,
|
||||
`event_expirations`, `event_vanish`, and the FTS row follow the header by
|
||||
`ON DELETE CASCADE` / trigger.
|
||||
|
||||
**STORE-W08 — batch insert.** One outer transaction, one SAVEPOINT per row: a bad row rolls
|
||||
back alone and reports `Rejected(reason)`; the rest commit. If the **outer commit** fails, every
|
||||
entry is treated as `Rejected` (the `IEventStore.batchInsert` contract). Outcomes are returned
|
||||
in input order; OK frames pair by event id, not order.
|
||||
|
||||
---
|
||||
|
||||
## Deletion lifecycle — NIP-09 / NIP-62 (STORE-D)
|
||||
|
||||
**STORE-D01 — delete by id.** A kind-5's `e` tags delete stored events with those ids **whose
|
||||
owner is the kind-5's author** (`pubkey_owner_hash` match — recipient for giftwraps per W06).
|
||||
The id path has **no timestamp condition**: it deletes the target regardless of the relative
|
||||
`created_at` values.
|
||||
|
||||
**STORE-D02 — delete by address.** A kind-5's `a` tags delete events at that
|
||||
`(kind, pubkey, d_tag)` coordinate with `created_at <= deletion.created_at` — **inclusive**; a
|
||||
version newer than the deletion survives. Only coordinates whose pubkey equals the kind-5's
|
||||
author are honored. Replaceable coordinates (`kind:pubkey:` with no d-tag) get the same
|
||||
`created_at <=` treatment against `(kind, pubkey)`.
|
||||
|
||||
**STORE-D03 — cross-author kind-5s are stored but inert.** A deletion naming someone else's
|
||||
events is inserted like any regular event (it may be useful to other relays/clients) but its
|
||||
delete pass removes zero rows and creates no blocking.
|
||||
|
||||
**STORE-D04 — re-insert blocking.** A `BEFORE INSERT` trigger rejects
|
||||
(`blocked: a deletion event exists`) any event whose id (`e`-hash) **or** address (`a`-hash) is
|
||||
named by a stored kind-5 from the same owner with `deletion.created_at >= event.created_at`.
|
||||
Note the asymmetry with D01: a *backdated* id-deletion (older `created_at` than its target)
|
||||
still deletes on arrival, but would not block a later re-insert.
|
||||
|
||||
**STORE-D05 — a kind-5 CAN delete another kind-5, and doing so un-blocks its targets.**
|
||||
Nothing excludes kind 5 from the id path (D01). Deleting a deletion removes its tombstone rows
|
||||
from `event_tags`, so events it had deleted become re-insertable. **Status: known quirk, not a
|
||||
considered decision.** NIP-09 leaves it open; at least one external implementation
|
||||
(vespa-eventstore) deliberately diverges by treating deletion-of-a-deletion as a no-op, which is
|
||||
the safer reading (tombstones shouldn't be revocable). If you change this, update this rule and
|
||||
the changelog — parity suites key off it.
|
||||
|
||||
**STORE-D06 — NIP-62 vanish is relay-scoped.** A kind-62 only cascades when
|
||||
`shouldVanishFrom(relay)` — its `relay` tags name this store's `relay` URL or `ALL_RELAYS`.
|
||||
(A store constructed with `relay = null` matches only `ALL_RELAYS` requests.) Out-of-scope
|
||||
vanish events are stored as regular events with no side effects.
|
||||
|
||||
**STORE-D07 — vanish scope and horizon.** An in-scope vanish deletes every event whose
|
||||
**owner** (W06) is the vanishing pubkey with `created_at < vanish.created_at` (strict — the
|
||||
vanish event itself survives), and blocks inserts of owned events with
|
||||
`created_at <= vanish.created_at` (`blocked: a request to vanish event exists`; note blocking is
|
||||
inclusive where deletion is strict). Newer vanish requests supersede older ones per pubkey
|
||||
(unique on `pubkey_hash`).
|
||||
|
||||
**STORE-D08 — manual deletes.** `delete(id)` removes one row unconditionally (no blocking
|
||||
created). `delete(filter)` deletes matching rows honoring per-filter limits, with the F10
|
||||
empty-filter no-op guard. Neither creates re-insert blocking — only stored kind-5/kind-62
|
||||
events do that.
|
||||
|
||||
---
|
||||
|
||||
## NIP-45 count (STORE-C)
|
||||
|
||||
**STORE-C01 — count = size of the deduped match set, honoring per-filter limits.** Single
|
||||
filter: `COUNT(*)` over that filter's row-id subquery (including its `LIMIT`, so
|
||||
`count(Filter(kinds=…, limit=10))` is at most 10). Multiple filters: branches are `UNION`ed
|
||||
(dedup) **before** counting — an event matching several filters counts once. FTS-off + search
|
||||
term → 0 (F-series search rules apply).
|
||||
|
||||
---
|
||||
|
||||
## NIP-50 search inside the store (STORE-S)
|
||||
|
||||
The indexing surface (which kinds are searchable, what text they contribute) is the
|
||||
`searchable-events` skill; these rules are the store's query-side contract.
|
||||
|
||||
**STORE-S01 — extension stripping at the store boundary.** Every filter-accepting method runs
|
||||
`strippingSearchExtensions()`: NIP-50 `key:value` tokens (`include:spam`, `domain:…`, …) are
|
||||
removed before FTS. Unsupported extensions are **ignored, never matched as literal text and
|
||||
never match-nothing** — an extensions-only search collapses to an unconstrained query. Stores
|
||||
that *do* implement extensions receive the raw string through the relay layer and parse it with
|
||||
`nip50Search.SearchQuery.parse` (see the `IEventStore` KDoc).
|
||||
|
||||
**STORE-S02 — relevance ordering.** Search results order by FTS5 `bm25` rank (best match
|
||||
first), with `created_at DESC` only as tiebreak; the `LIMIT` keeps the most *relevant* N, not
|
||||
the newest N. A multi-filter REQ is relevance-ordered only when **every** filter carries a
|
||||
search term (best/min rank per event across branches); mixing search and non-search filters
|
||||
falls back to `created_at DESC`.
|
||||
|
||||
**STORE-S03 — search combines by AND with the structural parts** (ids/authors/kinds/tags/
|
||||
since/until) of the same filter — an FTS `MATCH` join on top of the normal conditions.
|
||||
|
||||
**STORE-S04 — search grammar is SQLite FTS5 `MATCH`.** The raw (post-strip) string is passed to
|
||||
FTS5, so implicit-AND terms, `"phrase queries"`, `OR`, and `prefix*` follow FTS5 semantics.
|
||||
Tokenization details live in `FullTextSearchModule` (see `searchable-events`).
|
||||
|
||||
**STORE-S05 — FTS off.** With `IndexingStrategy.indexFullTextSearch = false`: a filter with a
|
||||
non-empty search term matches **nothing** (query/count/delete alike); an empty-string search
|
||||
imposes no constraint. Everything else is unchanged.
|
||||
|
||||
**STORE-S06 — deferred FTS.** Relays may set `deferFullTextSearchIndexing = true` (geode does):
|
||||
tokenization moves off the insert path to a watermark-driven catch-up
|
||||
(`needsFtsCatchUp`/`ftsCatchUp`), and search queries drain the backlog first — so NIP-50
|
||||
results are exactly as fresh as the synchronous path.
|
||||
|
||||
---
|
||||
|
||||
## Negentropy / NIP-77 (STORE-N)
|
||||
|
||||
**STORE-N01 —** `snapshotIdsForNegentropy(filters)` returns `(created_at, id)` pairs under the
|
||||
**same filter semantics as `query`** (per-filter limits included, multi-filter dedup), order
|
||||
unspecified (negentropy re-sorts). `maxEntries` returns up to `maxEntries + 1` as an overflow
|
||||
sentinel. `liveNegentropySnapshot` serves full-corpus NEG-OPENs from an in-memory index when
|
||||
`maintainLiveNegentropyIndex` is on; the delta plumbing in `SQLiteEventStore` keeps it exact
|
||||
across replaceable displacement, kind-5s, and vanish (invalidate-and-rebuild for the
|
||||
non-itemizable cases).
|
||||
|
||||
---
|
||||
|
||||
## Configuration presets
|
||||
|
||||
- **Client default** (`DefaultIndexingStrategy()`): FTS on (synchronous), optional indexes off,
|
||||
`useAndIndexIdOnOrderBy` off, no live negentropy index.
|
||||
- **Relay preset** (geode's `relayIndexingStrategy()`): adds created_at-alone, pubkey-alone and
|
||||
tag+kind+pubkey indexes, defers FTS, maintains the live negentropy index — still leaves
|
||||
`useAndIndexIdOnOrderBy` off.
|
||||
- Flag-gated indexes are runtime config, not schema: flipping one on an existing DB builds the
|
||||
index on next open (`ensureOptionalIndexes`), no migration.
|
||||
|
||||
## For parity implementers
|
||||
|
||||
- Treat the rule ids above as the vocabulary for divergence notes
|
||||
(e.g. "diverges from STORE-D05: we no-op deletion-of-a-deletion").
|
||||
- The commonTest suites are the executable spec; `FsParityTest` shows the in-repo pattern for
|
||||
holding a second engine to it.
|
||||
- Remember F06 (hash-based tag matching) and F08 (unordered same-second ties by default) when
|
||||
diffing results byte-for-byte — both are places where a "divergence" may be the reference's
|
||||
own slack, not your bug.
|
||||
|
||||
## Semantics changelog
|
||||
|
||||
Add one line per behavior change, newest first: `YYYY-MM-DD <short sha> <rule id> — what changed`.
|
||||
|
||||
- 2026-08-04 (baseline) — rules F01–F13, W01–W08, D01–D08, C01, S01–S06, N01 written from the
|
||||
code at the time this skill was introduced. Changes before this date are not itemized;
|
||||
archaeology starts at `git log` on `nip01Core/store/`.
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
name: nip85-trusted-assertions
|
||||
description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store.
|
||||
---
|
||||
|
||||
# NIP-85 Trusted Assertions — the Quartz model
|
||||
|
||||
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`.
|
||||
NIP-85 is still an evolving spec; **this package is the operative definition** of what
|
||||
Amethyst-family software writes and reads. This skill states the model (who signs what about
|
||||
whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume.
|
||||
|
||||
## The model in one paragraph
|
||||
|
||||
An **assertion is signed by the asserting party** (a trust provider service, or the user
|
||||
themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so
|
||||
"latest card by provider P about subject S" is just the addressable coordinate
|
||||
`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's
|
||||
**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch
|
||||
their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal
|
||||
event signatures; the 10040→assertion link is **consumer-side convention** (see
|
||||
"Authorization" below).
|
||||
|
||||
## Kind map
|
||||
|
||||
| Kind | Class | Kind class | d-tag = the subject | Content |
|
||||
|---|---|---|---|---|
|
||||
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
|
||||
| 30382 | `users/ContactCardEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
|
||||
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
|
||||
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
|
||||
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
|
||||
|
||||
Addresses: `ContactCardEvent.createAddress(owner, target)` → `Address(30382, owner, target)`
|
||||
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
|
||||
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
|
||||
|
||||
`ContactCardEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
|
||||
tags plus topics; the encrypted card content is intentionally never indexed.
|
||||
|
||||
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
|
||||
|
||||
There is **no fixed tag name**: `tag[0]` *is* the service string.
|
||||
|
||||
```json
|
||||
["30382:rank", "<provider pubkey, 64 hex>", "wss://nip85.brainstorm.world"]
|
||||
```
|
||||
|
||||
- `ServiceType(kind, type)` parses/renders `"<kind>:<type>"` — kind must be an int, the first
|
||||
`:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the
|
||||
allocation-free prefix check.
|
||||
- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey
|
||||
(length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) —
|
||||
entries failing any check are silently dropped, which is what keeps foreign tags like
|
||||
`["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`).
|
||||
- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take
|
||||
`isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value
|
||||
equality.
|
||||
- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types —
|
||||
`30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/
|
||||
`30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf →
|
||||
brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types
|
||||
Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`,
|
||||
`30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on.
|
||||
|
||||
## Authorization — what a consumer may assume
|
||||
|
||||
- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the
|
||||
observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming
|
||||
code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`:
|
||||
`rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the
|
||||
provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are
|
||||
simply ignored for trust purposes (they may still be stored; dropping them — as an external
|
||||
store's orphan sweep does — is a legitimate storage policy, not a protocol rule).
|
||||
- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that
|
||||
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
|
||||
metric (`liveUserRankProvider`, `liveUserFollowerCount` in
|
||||
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`).
|
||||
- **Multi-provider combination is unprescribed.** When two listed providers assert different
|
||||
ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per
|
||||
metric slot. Consumers choose their own policy — document it.
|
||||
- The relay URL in the entry is a **fetch hint, and it is honored**:
|
||||
`amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay
|
||||
(`kinds=[30382], authors=[provider], #d=[targets]`).
|
||||
|
||||
### The dual use of kind 30382
|
||||
|
||||
The same kind serves two roles, distinguished **by author**:
|
||||
|
||||
1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`,
|
||||
`followers`, `hops`, …); this is what 10040 discovery points at.
|
||||
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
|
||||
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
|
||||
the NIP-44 encrypted content, never in public tags** (`ContactCardEvent.build`/
|
||||
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
|
||||
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
|
||||
provider cards.
|
||||
|
||||
## Tag vocabulary and value semantics
|
||||
|
||||
All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric
|
||||
value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name, value.toString()]`.
|
||||
**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range
|
||||
validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) —
|
||||
consumers must defend.
|
||||
|
||||
**On 30382** (`users/tags/`, accessors on `ContactCardEvent` and as `TagArray` extensions in
|
||||
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
|
||||
|
||||
| Tag name | Accessor | Type | Semantics |
|
||||
|---|---|---|---|
|
||||
| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0–100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. |
|
||||
| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). |
|
||||
| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. |
|
||||
| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. |
|
||||
| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. |
|
||||
| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. |
|
||||
| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. |
|
||||
| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. |
|
||||
| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. |
|
||||
| `t` (repeatable) | `topics()` | List\<String> | Subject's topics/interests. |
|
||||
| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. |
|
||||
| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). |
|
||||
|
||||
**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`,
|
||||
`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats).
|
||||
**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`.
|
||||
|
||||
## Building and parsing (use the typed helpers, not raw `arrayOf`)
|
||||
|
||||
```kotlin
|
||||
// Provider list: declare a rank provider (this is what `amy graperank register` does)
|
||||
val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl)
|
||||
val list = TrustProviderListEvent.create(tag, isPrivate = false, signer)
|
||||
// or append to an existing one:
|
||||
val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer)
|
||||
val providers: List<ServiceProviderTag> = updated.serviceProviders() // public
|
||||
val private = updated.privateTags(signer)?.serviceProviders() // private side
|
||||
|
||||
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
|
||||
val card = ContactCardEvent.create(
|
||||
targetUser = subjectPubkey,
|
||||
signer = providerSigner,
|
||||
publicInitializer = {
|
||||
rank(87)
|
||||
followers(1234)
|
||||
hops(2)
|
||||
},
|
||||
)
|
||||
card.aboutUser() // d-tag → subject pubkey
|
||||
card.rank() // 87
|
||||
|
||||
// Event assertion: unsigned template only (30383/84/85 have build(), no create())
|
||||
val template = EventAssertionEvent.build(targetEventId) {
|
||||
rank(12)
|
||||
reactionCount(40)
|
||||
zapAmount(2100)
|
||||
}
|
||||
val signed = signer.sign(template)
|
||||
```
|
||||
|
||||
## Worked end-to-end example
|
||||
|
||||
Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`):
|
||||
|
||||
```json
|
||||
{ "kind": 10040, "pubkey": "<O>",
|
||||
"tags": [
|
||||
["30382:rank", "<P>", "wss://nip85.brainstorm.world"],
|
||||
["30382:followers", "<P>", "wss://nip85.brainstorm.world"]
|
||||
],
|
||||
"content": "" }
|
||||
```
|
||||
|
||||
Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:<P>:<S>`):
|
||||
|
||||
```json
|
||||
{ "kind": 30382, "pubkey": "<P>",
|
||||
"tags": [
|
||||
["d", "<S>"],
|
||||
["rank", "87"], ["followers", "1234"], ["hops", "2"]
|
||||
],
|
||||
"content": "" }
|
||||
```
|
||||
|
||||
`P` asserts about an event `E` (kind 30383, addressable at `30383:<P>:<E>`):
|
||||
|
||||
```json
|
||||
{ "kind": 30383, "pubkey": "<P>",
|
||||
"tags": [["d", "<E>"], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]],
|
||||
"content": "" }
|
||||
```
|
||||
|
||||
Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe
|
||||
`{kinds:[30382], authors:["<P>"], "#d":["<S>", …]}` at the hinted relay → newest card per
|
||||
address wins → `rank()`.
|
||||
|
||||
Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real
|
||||
10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four
|
||||
assertion kinds with every tag populated).
|
||||
|
||||
## Freshness / supersession
|
||||
|
||||
Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag
|
||||
convention and **no prescribed refresh cadence** — staleness policy is the consumer's.
|
||||
Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when
|
||||
`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the
|
||||
card's `a`-tag (`30382:<provider>:<target>`).
|
||||
|
||||
## Stability notes (as of 2026-08)
|
||||
|
||||
- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape;
|
||||
`rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint
|
||||
consumption.
|
||||
- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/
|
||||
zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders +
|
||||
tests exist; no in-repo publisher yet).
|
||||
- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array<Any>` —
|
||||
dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params
|
||||
are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/`
|
||||
(stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d`
|
||||
tag is reflected by `dTag()` even though the convention is `""`.
|
||||
|
||||
## Where it's consumed (reading list)
|
||||
|
||||
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
|
||||
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
|
||||
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
|
||||
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
|
||||
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
|
||||
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
|
||||
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
---
|
||||
name: searchable-events
|
||||
description: The NIP-50 indexing surface of Quartz — the `SearchableEvent` interface, which event kinds are searchable, exactly what text each kind's `indexableContent()` contributes, how the SQLite/filesystem stores consume it, and the NIP-50 `SearchQuery` extension grammar plus `SearchRelayListEvent` (kind 10007). Use when making a kind searchable, changing what a kind indexes, diffing the searchable set at a Quartz version bump (external search engines mirror this table), debugging why an event is or isn't found by search, or working with search extensions (`include:spam`, `domain:`, …).
|
||||
---
|
||||
|
||||
# Searchable Events — the NIP-50 indexing surface
|
||||
|
||||
## The contract
|
||||
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip50Search/SearchableEvent.kt`:
|
||||
|
||||
```kotlin
|
||||
interface SearchableEvent {
|
||||
fun indexableContent(): String
|
||||
}
|
||||
```
|
||||
|
||||
One method; marker and extractor in one. An event kind is searchable **iff** its event class
|
||||
implements this interface **and** the class is wired into `EventFactory` (the stores probe
|
||||
searchability by kind through `EventFactory.create` — an unwired implementor is invisible).
|
||||
|
||||
Rules every implementation follows (keep them when adding one):
|
||||
|
||||
- **Plain text out.** Return the human-meaningful fields joined with `"\n"` (a handful of
|
||||
metadata-ish kinds use `" "`); no markup stripping is performed — markdown/asciidoc content
|
||||
goes in raw, JSON-content kinds (kind 0 metadata, marketplace stalls, channel info) **parse
|
||||
first and join the extracted fields**, never the raw JSON.
|
||||
- **Never throw, never null.** There is no defensive wrapper at any call site; a throw aborts
|
||||
the insert transaction. Parsed-JSON implementations use `?.let { … } ?: ""`.
|
||||
- **Only public data.** Encrypted content stays out (e.g. kind 30382 contact cards index only
|
||||
the public petname/summary/topics, never the NIP-44 payload).
|
||||
- Typical shapes: `content` alone (~33 kinds); `listOfNotNull(title(), content)`;
|
||||
`listOfNotNull(title(), summary(), content)`; lists index `title() + description()`.
|
||||
|
||||
## The full kind table
|
||||
|
||||
**`references/searchable-kinds.md`** in this skill holds the authoritative table — every
|
||||
implementor with its kind number, class, and the exact `indexableContent()` expression
|
||||
(126 concrete classes / 129 kind values as of 2026-08). Diff that file at a version bump to
|
||||
answer "did the searchable set or any kind's indexed text change?".
|
||||
|
||||
Notables that surprise people:
|
||||
|
||||
- **Kind 9735 (zap receipt) indexes the embedded zap request's content**
|
||||
(`zapRequest?.content.orEmpty()`) — receipts are searchable by the zapper's comment.
|
||||
- **Kind 0 / 31990** index many profile fields space-joined (name, about, nip05, lud16,
|
||||
website, picture URL, …).
|
||||
- **Kind 30063 is claimed twice** (`ReleaseArtifactSetEvent` in nip51Lists and the experimental
|
||||
`SoftwareReleaseEvent`); `EventFactory` resolves 30063 to `ReleaseArtifactSetEvent`, so
|
||||
`title()\ndescription()` is what actually gets indexed — `SoftwareReleaseEvent.indexableContent()`
|
||||
is dead on the store path.
|
||||
- Poll kinds (1068, 6969) append each option label on its own line.
|
||||
|
||||
## MANDATORY maintenance when you touch this surface
|
||||
|
||||
Adding `SearchableEvent` to a kind, removing it, or changing any `indexableContent()` body:
|
||||
|
||||
1. **Update `references/searchable-kinds.md`** in the same PR (external search engines — e.g.
|
||||
the Vespa-backed store's `SearchExtractors` — mirror this table at pin bumps; a silent
|
||||
change ships them stale search results).
|
||||
2. **Remember existing databases don't reindex themselves.** Old rows keep their old (or
|
||||
missing) FTS text until `IEventStore.reindexFullTextSearch()` runs — the KDoc on that method
|
||||
is the contract. App-side, schedule the resumable overload after shipping such a change.
|
||||
3. New implementors must be **registered in `EventFactory`** or the reindex scan and kind
|
||||
pre-filter (`FullTextSearchModule.isSearchableKind`) will never see them.
|
||||
|
||||
Eligibility policy: a kind becomes searchable when it carries human-authored, human-meaningful
|
||||
text (titles, bodies, names, descriptions). Pure-machine kinds (reactions, follow lists, zaps
|
||||
minus their comment, relay lists) stay out to keep the index small.
|
||||
|
||||
## How the stores consume it
|
||||
|
||||
**SQLite** (`nip01Core/store/sqlite/FullTextSearchModule.kt`):
|
||||
`CREATE VIRTUAL TABLE event_fts USING fts5(content, content='', contentless_delete=1)` —
|
||||
contentless, `rowid` = `event_headers.row_id`, an `AFTER DELETE` trigger keeps it in sync. On
|
||||
insert (when FTS is on and not deferred): `if (event is SearchableEvent)` → bind
|
||||
`event.indexableContent()` — the only method ever called. Tokenization is entirely SQLite's
|
||||
default FTS5 `unicode61`; queries are always a bound `event_fts MATCH ?` (never concatenated),
|
||||
ordered by bm25 `rank` then `created_at DESC`. Query-side semantics (relevance ordering,
|
||||
extension stripping, FTS-off behavior, deferred catch-up) are rules STORE-S01…S06 in the
|
||||
`event-store-semantics` skill.
|
||||
|
||||
**Filesystem store** (`jvmMain/.../store/fs/FsIndexer.kt` + `FsSearchTokenizer.kt`): tokenizes
|
||||
`indexableContent()` itself, approximating `unicode61` (split on non-letter/digit, lowercase);
|
||||
the same tokenizer runs on queries so drift cancels.
|
||||
|
||||
## NIP-50 client side
|
||||
|
||||
**`SearchQuery`** (`nip50Search/SearchQuery.kt`) — typed parse of the `search` filter string
|
||||
into `terms` + `extensions`. A whitespace token is an extension iff it looks like
|
||||
`lowercasekey:value` (the value not starting with `//`, so URLs stay free text); duplicate keys
|
||||
keep the last; unknown extensions are preserved (`extension(key)`). Typed accessors:
|
||||
`includeSpam`, `domain`, `language`, `sentiment`, `nsfw`. `stripExtensions()` /
|
||||
`Filter.strippingSearchExtensions()` is the bridge the built-in stores use — unsupported
|
||||
extensions are **ignored** (NIP-50), so an extensions-only search collapses to an unconstrained
|
||||
query, never match-nothing. A server-side store that implements its own extensions
|
||||
(`observer:`, `sort:rank`, …) receives the raw string (see the `IEventStore` KDoc) and should
|
||||
parse with `SearchQuery.parse` so its syntax stays compatible with what clients send.
|
||||
|
||||
**`SearchRelayListEvent`** — **kind 10007**, the user's search-relay list (NIP-51-style, public
|
||||
tags + NIP-44 private tags; *not* a `SearchableEvent` itself). Client consumption:
|
||||
`commons/.../actions/SearchActions.kt`, bootstrap defaults in
|
||||
`commons/.../account/AccountBootstrapEvents.kt`.
|
||||
|
||||
Don't confuse it with `commons/.../commons/search/SearchQuery.kt` — an app-level local-feed
|
||||
query model (authors/kinds/hashtags/or-terms), unrelated to the NIP-50 wire string.
|
||||
|
||||
## Tests (executable spec)
|
||||
|
||||
- `commonTest/.../nip50Search/SearchQueryTest.kt` — the extension grammar, token by token.
|
||||
- `commonTest/.../store/sqlite/SearchTest.kt` — per-kind indexing (kind 0 profile fields,
|
||||
40/41 channel JSON, 31924/30617), extension-token ignoring, reindex/resumable-reindex,
|
||||
FTS cleanup on replaceable rotation.
|
||||
- `commonTest/.../store/sqlite/SearchRelevanceOrderTest.kt` — bm25-before-recency ordering,
|
||||
limit-after-score, multi-filter rank union.
|
||||
- `commonTest/.../store/sqlite/NoFullTextSearchTest.kt` — FTS-off contract.
|
||||
- `jvmTest/.../store/fs/FsSearchTest.kt` — tokenizer parity for the filesystem store.
|
||||
@@ -0,0 +1,166 @@
|
||||
# Searchable kinds — the authoritative implementor table
|
||||
|
||||
Every concrete `SearchableEvent` implementor in Quartz, with the exact `indexableContent()`
|
||||
expression. **Update this file in the same PR as any change to the searchable set or to an
|
||||
`indexableContent()` body** (see SKILL.md). Verified against the code 2026-08-04.
|
||||
|
||||
Counts: 126 concrete classes covering 129 kind values (`GitStatusEvent` spans 4 kinds;
|
||||
kind 30063 has a collision — see the footnote). File paths are under
|
||||
`quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/`.
|
||||
|
||||
Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
|
||||
|
||||
| Kind | Class | Package | `indexableContent()` |
|
||||
|---|---|---|---|
|
||||
| 0 | MetadataEvent | nip01Core/metadata | `contactMetaData()?.let { listOfNotNull(it.name, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner).joinToString(" ") } ?: ""` (SP) |
|
||||
| 1 | TextNoteEvent | nip10Notes | `listOfNotNull(subject(), content)` NL |
|
||||
| 9 | ChatEvent | nipC7Chats | `content` |
|
||||
| 11 | ThreadEvent | nip7DThreads | `listOfNotNull(title(), content)` NL |
|
||||
| 14 | ChatMessageEvent | nip17Dm/messages | `content` |
|
||||
| 20 | PictureEvent | nip68Picture | `listOfNotNull(title(), content)` NL |
|
||||
| 21 | VideoNormalEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 22 | VideoShortEvent | nip71Video | inherited `RegularVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 24 | PublicMessageEvent | nipA4PublicMessages | `content` |
|
||||
| 40 | ChannelCreateEvent | nip28PublicChat/admin | `channelInfo().let { listOfNotNull(it.name, it.about, it.picture).joinToString(" ") }` (SP) |
|
||||
| 41 | ChannelMetadataEvent | nip28PublicChat/admin | same as kind 40 (SP) |
|
||||
| 42 | ChannelMessageEvent | nip28PublicChat/message | `content` |
|
||||
| 54 | PodcastEpisodeEvent | nipF4Podcasts/episode | `listOfNotNull(title(), description(), content)` NL |
|
||||
| 1010 | TextNoteModificationEvent | experimental/edits | `listOfNotNull(content, summary())` NL (content first) |
|
||||
| 1063 | FileHeaderEvent | nip94FileMetadata | `listOfNotNull(summary(), content)` NL |
|
||||
| 1065 | FileStorageHeaderEvent | experimental/nip95/header | `listOfNotNull(summary())` NL |
|
||||
| 1068 | PollEvent | nip88Polls/poll | `buildString { append(content); options().forEach { append('\n').append(it.label) } }` |
|
||||
| 1111 | CommentEvent | nip22Comments | `(listOf(content) + tags.hashtags())` NL |
|
||||
| 1163 | ProfileGalleryEntryEvent | experimental/profileGallery | `listOfNotNull(summary())` NL |
|
||||
| 1301 | WorkoutRecordEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
|
||||
| 1311 | LiveActivitiesChatMessageEvent | nip53LiveActivities/chat | `(listOf(content) + tags.hashtags())` NL |
|
||||
| 1312 | LiveActivitiesRaidEvent | nip53LiveActivities/raid | `content` |
|
||||
| 1313 | LiveActivitiesClipEvent | nip53LiveActivities/clip | `listOfNotNull(title(), content)` NL |
|
||||
| 1315 | RoadEventReportEvent | experimental/roadstr/report | `content` |
|
||||
| 1337 | CodeSnippetEvent | nipC0CodeSnippets | `listOfNotNull(snippetName(), snippetDescription(), content)` NL |
|
||||
| 1617 | GitPatchEvent | nip34Git/patch | `content` |
|
||||
| 1618 | GitPullRequestEvent | nip34Git/pr | `listOfNotNull(subject(), content)` NL |
|
||||
| 1621 | GitIssueEvent | nip34Git/issue | `listOfNotNull(subject(), content)` NL |
|
||||
| 1622 | GitReplyEvent | nip34Git/reply | `content` |
|
||||
| 1630–1633 | GitStatusEvent | nip34Git/status | `content` (open/applied/closed/draft) |
|
||||
| 1808 | AudioHeaderEvent | experimental/audio/header | `content` |
|
||||
| 1985 | LabelEvent | nip32Labeling | `(listOf(content) + labels().map { it.label }).filter { it.isNotEmpty() }` NL |
|
||||
| 2003 | TorrentEvent | nip35Torrents | `listOfNotNull(title(), content)` NL |
|
||||
| 2004 | TorrentCommentEvent | nip35Torrents | `content` |
|
||||
| 2473 | BirdDetectionEvent | experimental/birdstar | `listOfNotNull(summary(), speciesName())` NL |
|
||||
| 3302 | ConcordChatEditEvent | concord/cord03Channels | `content` |
|
||||
| 5050 | NIP90TextGenerationRequestEvent | nip90Dvms/textGeneration | `inputs().filter { it.type == "prompt" \|\| it.type == "text" }.joinToString(" ") { it.value }` (SP) |
|
||||
| 5100 | NIP90ImageGenerationRequestEvent | nip90Dvms/imageGeneration | `listOfNotNull(prompt(), negativePrompt()).joinToString(" ")` (SP) |
|
||||
| 5129 | NappletSnapshotEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 5250 | NIP90TextToSpeechRequestEvent | nip90Dvms/textToSpeech | `text() ?: ""` |
|
||||
| 5302 | NIP90ContentSearchRequestEvent | nip90Dvms/contentSearch | `searchQuery() ?: ""` |
|
||||
| 5303 | NIP90PeopleSearchRequestEvent | nip90Dvms/peopleSearch | `searchQuery() ?: ""` |
|
||||
| 6969 | ZapPollEvent | experimental/zapPolls | `buildString { append(content); pollOptionsArray().forEach { append('\n').append(it.descriptor) } }` |
|
||||
| 8333 | OnchainZapEvent | nipBCOnchainZaps/zap | `content` |
|
||||
| 9002 | EditMetadataEvent | nip29RelayGroups/moderation | `(listOfNotNull(name(), about()) + hashtags())` NL |
|
||||
| 9041 | GoalEvent | nip75ZapGoals | `listOfNotNull(summary(), content)` NL |
|
||||
| 9321 | NutzapEvent | nip61Nutzaps/nutzap | `content` |
|
||||
| 9734 | LnZapRequestEvent | nip57Zaps | `content` |
|
||||
| 9735 | LnZapEvent | nip57Zaps | `zapRequest?.content.orEmpty()` — indexes the **embedded 9734's** content |
|
||||
| 9736 | Bolt12ZapEvent | nipB1Bolt12Zaps/zap | `content` |
|
||||
| 9737 | Bolt12ZapIntentEvent | nipB1Bolt12Zaps/intent | `content` |
|
||||
| 9802 | HighlightEvent | nip84Highlights | `listOfNotNull(comment(), context(), content)` NL |
|
||||
| 10003 | BookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
|
||||
| 10100 | AgentProfileEvent | buzz/agentProfiles | `profileOrNull()?.let { listOfNotNull(it.name, it.displayName).joinToString("\n") } ?: ""` |
|
||||
| 10154 | PodcastMetadataEvent | nipF4Podcasts/metadata | `listOfNotNull(title(), description())` NL |
|
||||
| 11871 | AttestorProficiencyEvent | experimental/attestations/proficiency | `listOfNotNull(description())` NL |
|
||||
| 12473 | BirdexEvent | experimental/birdstar | `(listOfNotNull(summary()) + speciesNames())` NL |
|
||||
| 15128 | RootSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
|
||||
| 15129 | RootNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 30000 | PeopleListEvent | nip51Lists/peopleList | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30001 | OldBookmarkListEvent | nip51Lists/bookmarkList | `listOfNotNull(title())` NL |
|
||||
| 30002 | RelaySetEvent | nip51Lists/relaySets | `listOfNotNull(title(), description())` NL |
|
||||
| 30003 | LabeledBookmarkListEvent | nip51Lists/labeledBookmarkList | `listOfNotNull(titleOrName(), description())` NL |
|
||||
| 30004 | ArticleCurationSetEvent | nip51Lists/articleCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30005 | VideoCurationSetEvent | nip51Lists/videoCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30006 | PictureCurationSetEvent | nip51Lists/pictureCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30009 | BadgeDefinitionEvent | nip58Badges/definition | `listOfNotNull(name(), description(), content)` NL |
|
||||
| 30015 | InterestSetEvent | nip51Lists/interestSet | `(listOfNotNull(title(), description()) + publicHashtags())` NL |
|
||||
| 30017 | StallEvent | nip15Marketplace/stall | `stallData()?.let { listOfNotNull(it.name, it.description).joinToString("\n") } ?: ""` |
|
||||
| 30018 | ProductEvent | nip15Marketplace/product | `productData()?.let { (listOfNotNull(it.name, it.description) + categories()).joinToString("\n") } ?: ""` |
|
||||
| 30019 | MarketplaceEvent | nip15Marketplace/marketplace | `marketplaceData()?.let { listOfNotNull(it.name, it.about).joinToString("\n") } ?: ""` |
|
||||
| 30020 | AuctionEvent | nip15Marketplace/auction | `auctionData()?.let { (listOfNotNull(it.name, it.description) + tags.hashtags()).joinToString("\n") } ?: ""` |
|
||||
| 30023 | LongTextNoteEvent | nip23LongContent | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30030 | EmojiPackEvent | nip30CustomEmoji/pack | `listOfNotNull(titleOrName(), description(), content)` NL |
|
||||
| 30054 | Podcasting20EpisodeEvent | nipXXPodcasting20/episode | `(listOfNotNull(title(), description(), content) + topics())` NL |
|
||||
| 30055 | Podcasting20TrailerEvent | nipXXPodcasting20/trailer | `listOfNotNull(title(), content)` NL |
|
||||
| 30063 | ReleaseArtifactSetEvent † | nip51Lists/releaseArtifactSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30175 | PersonaEvent | buzz/apPersonas | `personaOrNull()?.let { listOfNotNull(it.displayName, it.systemPrompt).joinToString("\n") } ?: ""` |
|
||||
| 30176 | TeamEvent | buzz/teams | `teamOrNull()?.let { listOfNotNull(it.name, it.description, it.instructions).joinToString("\n") } ?: ""` |
|
||||
| 30177 | ManagedAgentEvent | buzz/managedAgents | `agentOrNull()?.let { listOfNotNull(it.name, it.systemPrompt).joinToString("\n") } ?: ""` |
|
||||
| 30267 | AppCurationSetEvent | nip51Lists/appCurationSet | `listOfNotNull(title(), description())` NL |
|
||||
| 30296 | InteractiveStoryPrologueEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30297 | InteractiveStorySceneEvent | experimental/interactiveStories | inherited base: `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30311 | LiveActivitiesEvent | nip53LiveActivities/streaming | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30312 | MeetingSpaceEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(room(), summary(), content)` NL |
|
||||
| 30313 | MeetingRoomEvent | nip53LiveActivities/meetingSpaces | `listOfNotNull(title(), summary())` NL |
|
||||
| 30315 | StatusEvent | nip38UserStatus | `content` |
|
||||
| 30382 | ContactCardEvent | nip85TrustedAssertions/users | `(listOfNotNull(petName(), summary()) + topics())` NL — public tags only, never the NIP-44 content |
|
||||
| 30402 | ClassifiedsEvent | nip99Classifieds | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 30617 | GitRepositoryEvent | nip34Git/repository | `listOfNotNull(name(), description(), content)` NL |
|
||||
| 30620 | WorkflowDefEvent | buzz/workflow | `listOfNotNull(name(), content)` NL |
|
||||
| 30817 | NipTextEvent | experimental/nipsOnNostr | `listOfNotNull(title(), content)` NL |
|
||||
| 30818 | WikiNoteEvent | nip54Wiki | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31337 | AudioTrackEvent | experimental/audio/track | `listOfNotNull(subject())` NL |
|
||||
| 31871 | AttestationEvent | experimental/attestations/attestation | `content` |
|
||||
| 31872 | AttestationRequestEvent | experimental/attestations/request | `content` |
|
||||
| 31873 | AttestorRecommendationEvent | experimental/attestations/recommendation | `listOfNotNull(description())` NL |
|
||||
| 31890 | FeedDefinitionEvent | feedDefinition | `title().orEmpty()` |
|
||||
| 31922 | CalendarDateSlotEvent | nip52Calendar/appt/day | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31923 | CalendarTimeSlotEvent | nip52Calendar/appt/time | `listOfNotNull(title(), summary(), content)` NL |
|
||||
| 31924 | CalendarEvent | nip52Calendar/calendar | `listOfNotNull(title(), content)` NL |
|
||||
| 31925 | CalendarRSVPEvent | nip52Calendar/rsvp | `content` |
|
||||
| 31990 | AppDefinitionEvent | nip89AppHandlers/definition | `appMetaData()?.let { listOfNotNull(it.name, it.username, it.displayName, it.about, it.nip05, it.lud06, it.lud16, it.website, it.picture, it.banner, it.image).joinToString(" ") } ?: ""` (SP) |
|
||||
| 32267 | SoftwareApplicationEvent | experimental/nip82SoftwareApps/application | `listOfNotNull(name(), summary(), content)` NL |
|
||||
| 33401 | ExerciseTemplateEvent | experimental/fitness/workout | `listOfNotNull(title(), content)` NL |
|
||||
| 33863 | FundraiserEvent | experimental/agora | `listOfNotNull(title(), content)` NL |
|
||||
| 34139 | MusicPlaylistEvent | experimental/music/playlist | `listOfNotNull(title(), description(), content)` NL |
|
||||
| 34235 | VideoHorizontalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34236 | VideoVerticalEvent | nip71Video | inherited `AddressableVideoEvent`: `listOfNotNull(title(), content)` NL |
|
||||
| 34550 | CommunityDefinitionEvent | nip72ModCommunities/definition | `listOfNotNull(name(), description(), rules(), content)` NL |
|
||||
| 35128 | NamedSiteEvent | nip5aStaticWebsites | `listOfNotNull(title(), description())` NL |
|
||||
| 35129 | NamedNappletEvent | nip5dNapplets | `listOfNotNull(title(), description())` NL |
|
||||
| 36787 | MusicTrackEvent | experimental/music/track | `listOfNotNull(title(), artist(), album(), content)` NL |
|
||||
| 38000 | MintRecommendationEvent | nip87Ecash/recommendation | `content` |
|
||||
| 38192 | Ps1SaveEvent | experimental/ps1saves | `listOfNotNull(summary(), saveTitle(), region(), filename())` NL |
|
||||
| 38383 | P2POrderEvent | nip69P2pOrderEvents | `(listOfNotNull(makerName(), currency()) + paymentMethods().orEmpty()).joinToString(" ")` (SP) |
|
||||
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
|
||||
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
|
||||
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
|
||||
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
|
||||
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
|
||||
| 40100 | CanvasEvent | buzz/stream | `content` |
|
||||
| 45001 | ForumPostEvent | buzz/forum | `content` |
|
||||
| 45003 | ForumCommentEvent | buzz/forum | `content` |
|
||||
| 48106 | HuddleGuidelinesEvent | buzz/huddles | `content` |
|
||||
|
||||
† **Kind 30063 collision:** `experimental/nip82SoftwareApps/release/SoftwareReleaseEvent` also
|
||||
declares `KIND = 30063` and implements `SearchableEvent` (`content`), but `EventFactory` maps
|
||||
30063 to `ReleaseArtifactSetEvent`, so on every store path kind 30063 indexes
|
||||
`title()\ndescription()`. If the factory mapping ever changes, this table changes with it.
|
||||
|
||||
## Abstract bases (no kind of their own)
|
||||
|
||||
| Base class | Body | Concrete kinds |
|
||||
|---|---|---|
|
||||
| `InteractiveStoryBaseEvent` | `listOfNotNull(title(), summary(), content)` NL | 30296, 30297 |
|
||||
| `AddressableVideoEvent` | `listOfNotNull(title(), content)` NL | 34235, 34236 |
|
||||
| `RegularVideoEvent` | `listOfNotNull(title(), content)` NL | 21, 22 |
|
||||
|
||||
## How to regenerate / verify this table
|
||||
|
||||
```bash
|
||||
# All implementor files:
|
||||
grep -rln "override fun indexableContent" quartz/src/commonMain
|
||||
# For each, pair the KIND constant with the indexableContent() body.
|
||||
# Searchability on the store path additionally requires EventFactory registration:
|
||||
grep -n "<ClassName>" quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt
|
||||
```
|
||||
|
||||
A CI-diffable snapshot test (assert the set of kinds whose `EventFactory` product implements
|
||||
`SearchableEvent` against a checked-in list) would make this table impossible to go stale —
|
||||
suggested follow-up, not yet implemented.
|
||||
@@ -26,8 +26,12 @@ env:
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
|
||||
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
|
||||
# embeds the AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
#
|
||||
# Both arch binaries come from the same appimagetool release so their SHA256
|
||||
# values move in lockstep on version bumps.
|
||||
APPIMAGETOOL_VERSION: '1.9.0'
|
||||
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -38,11 +42,32 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
|
||||
# ubuntu-24.04-arm label is a standard free public-repo runner as of
|
||||
# early 2025). Windows arm64 uses windows-11-arm, added to the free
|
||||
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
|
||||
# jpackage / jlink / Compose Multiplatform 1.11 all produce
|
||||
# host-native artifacts — no cross-compilation needed.
|
||||
#
|
||||
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
|
||||
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
|
||||
# windows-11-arm runner image ships no WiX (the windows-latest image
|
||||
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
|
||||
# MSI). Installing it here would mean pulling an archived, x86-only
|
||||
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
|
||||
# the candle/light CLI that JDK 21's jpackage requires) into the job
|
||||
# that publishes signed release assets. The portable zip is the
|
||||
# documented Windows install path for amy/geode already, so arm64
|
||||
# Windows users get that until either the runner image gains WiX or
|
||||
# jpackage learns the WiX 4+ CLI.
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
@@ -93,13 +118,21 @@ jobs:
|
||||
set -euo pipefail
|
||||
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
|
||||
sudo apt-get update && sudo apt-get install -y desktop-file-utils
|
||||
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
|
||||
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
|
||||
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
|
||||
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
curl -fsSL --retry 3 "$URL" -o "$DEST"
|
||||
actual=$(sha256sum "$DEST" | awk '{print $1}')
|
||||
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
chmod +x "$DEST"
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
@@ -203,17 +236,32 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
|
||||
# against the bundled JRE under lib/runtime/, NOT the app payload under
|
||||
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
|
||||
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
|
||||
# install without EGL crashes at startup with:
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
if: matrix.family == 'linux' && matrix.arch == 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Build portable archives (windows + linux-portable)
|
||||
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ARCH="${{ matrix.arch }}"
|
||||
APP="desktopApp/build/compose/binaries/main-release/app"
|
||||
mkdir -p desktopApp/build/portable
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
|
||||
else
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
@@ -230,6 +278,17 @@ jobs:
|
||||
PKG="desktopApp/packaging/flatpak"
|
||||
APP_ID="com.vitorpamplona.amethyst.Desktop"
|
||||
OUT="desktopApp/build/flatpak"
|
||||
# AppImage-style arch names for the bundle filename.
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
|
||||
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
|
||||
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
# Rewrite the arch-specific GStreamer plugin path in the manifest
|
||||
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
|
||||
# matches the original triplet.
|
||||
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
# Inject the AppStream <release> entry for this build (the checked-in
|
||||
# metainfo deliberately carries none — CI is the source of truth).
|
||||
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
|
||||
@@ -241,7 +300,7 @@ jobs:
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
@@ -325,8 +384,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
|
||||
# and jpackageMsi for a CLI is deferred (the portable zip is the
|
||||
# documented Windows install path). The launcher script writes both
|
||||
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
|
||||
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
|
||||
# zips the image on Windows instead of tar.gz.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -574,8 +642,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
|
||||
# deferred (portable zip covers the primary use — operators still
|
||||
# deploy geode via the Docker image or the tarball on Linux). The
|
||||
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
|
||||
# smoke test below runs under bash on the windows runner.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -630,12 +706,23 @@ jobs:
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
#
|
||||
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
|
||||
# tmp path also differs between git-bash on Windows (which resolves /tmp
|
||||
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
|
||||
# because the workflow's `defaults.run.shell: bash` uses git-bash on
|
||||
# Windows and /tmp is a valid mingw path there.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
LAUNCHER="$IMG/bin/geode.bat"
|
||||
else
|
||||
LAUNCHER="$IMG/bin/geode"
|
||||
fi
|
||||
"$LAUNCHER" --version
|
||||
"$LAUNCHER" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
|
||||
@@ -49,9 +49,17 @@ jobs:
|
||||
# package, installs it, and verifies the process stays alive for 10s.
|
||||
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
|
||||
# (java.management, java.prefs), and native lib bundling issues.
|
||||
#
|
||||
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
|
||||
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
|
||||
# at PR time instead of on the tag build.
|
||||
# -------------------------------------------------------------------------
|
||||
release-deb-launch:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -84,13 +92,29 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# Mirrors the same step in create-release.yml so this job exercises the
|
||||
# exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in
|
||||
# DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without
|
||||
# this the arm64 app dies at startup with
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# See scripts/add-deb-libegl-dep.sh for the full rationale.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Install .deb
|
||||
run: |
|
||||
# Installed via apt (not `dpkg -i`) so the .deb's declared Depends are
|
||||
# actually resolved — that is what pulls in libegl1 on the arm64
|
||||
# runner, which does not ship it preinstalled.
|
||||
#
|
||||
# jpackage's post-install script runs xdg-desktop-menu which fails
|
||||
# on CI runners ("No writable system menu directory"). The files are
|
||||
# extracted successfully; only the menu registration fails. Allow the
|
||||
# dpkg error, then verify the binary was actually installed.
|
||||
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
# install error, then verify the binary was actually installed.
|
||||
sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
echo "Installed files:"
|
||||
dpkg -L amethyst | head -30
|
||||
# Fail if the binary wasn't actually extracted
|
||||
@@ -139,5 +163,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Release DEB (smoke-tested)
|
||||
# Artifact names must be unique across a run — disambiguate per arch.
|
||||
name: Release DEB (smoke-tested, ${{ matrix.os }})
|
||||
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
+33
-12
@@ -35,7 +35,12 @@ All platforms:
|
||||
Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
|
||||
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
|
||||
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
|
||||
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
|
||||
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
|
||||
themselves are x86 and run under emulation).
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
@@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
|
||||
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
|
||||
sudo apt-get install -y desktop-file-utils
|
||||
|
||||
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
|
||||
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
|
||||
ARCH="$(uname -m)"
|
||||
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources.
|
||||
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
|
||||
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
|
||||
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -325,14 +333,27 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
4. **Verify** — the GH Release should hold **47 assets**:
|
||||
- **14 desktop**, one per matrix leg × format:
|
||||
- macOS arm64: `dmg` (1)
|
||||
- Windows x64: `msi` + portable `zip` (2)
|
||||
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
|
||||
- Linux x64 / arm64: `deb` + `rpm` (4)
|
||||
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
|
||||
|
||||
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
|
||||
and no Intel runner leg is configured, so macOS ships arm64-only.
|
||||
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
|
||||
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
|
||||
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
|
||||
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
|
||||
jpackage learns the WiX 4+ `wix build` CLI.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
|
||||
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
|
||||
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
|
||||
- **10 geode** — same shape as amy.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Android flow unchanged
|
||||
|
||||
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
/**
|
||||
* No translation service in this flavor, so no note is ever translated and the copy-text
|
||||
* menus never need to offer a "Copy Translated" option.
|
||||
*/
|
||||
fun cachedTranslation(
|
||||
content: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String? = null
|
||||
@@ -723,6 +723,7 @@ class AppModules(
|
||||
torManager.status,
|
||||
client,
|
||||
applicationIOScope,
|
||||
onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) },
|
||||
)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
|
||||
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
|
||||
return when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: "Napplet",
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = "",
|
||||
)
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: event.identifier(),
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = event.identifier(),
|
||||
)
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
|
||||
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.marmot.MarmotManager
|
||||
import com.vitorpamplona.amethyst.commons.model.IAccount
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
@@ -59,6 +61,7 @@ import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCa
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
|
||||
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
|
||||
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderAccount
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthCustomToggles
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPermissionStore
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -150,6 +153,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCPaymentF
|
||||
import com.vitorpamplona.amethyst.service.uploads.FileHeader
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThread
|
||||
import com.vitorpamplona.quartz.buzz.threading.buzzThreadReply
|
||||
@@ -285,6 +289,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
|
||||
@@ -353,7 +358,8 @@ class Account(
|
||||
relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(),
|
||||
signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(),
|
||||
nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(),
|
||||
) : IAccount {
|
||||
) : IAccount,
|
||||
UserFinderAccount {
|
||||
private var userProfileCache: User? = null
|
||||
|
||||
override fun userProfile(): User = userProfileCache ?: cache.getOrCreateUser(signer.pubKey).also { userProfileCache = it }
|
||||
@@ -365,6 +371,34 @@ class Account(
|
||||
override val hiddenUsersHashCodes: Set<Int> get() = hiddenUsers.flow.value.hiddenUsersHashCodes
|
||||
override val spammersHashCodes: Set<Int> get() = hiddenUsers.flow.value.spammersHashCodes
|
||||
|
||||
// UserFinderAccount — narrow, read-only relay-hint view used by the shared
|
||||
// per-user metadata + per-note event finders (moved to commons). Snapshot
|
||||
// getters read `.value` fresh on every filter rebuild. userFinderPubkeyHex
|
||||
// doubles as the attribution pubkey for ExplainedFilter.accountPubKeys.
|
||||
override val userFinderPubkeyHex: HexKey get() = userProfile().pubkeyHex
|
||||
|
||||
override fun indexRelays(): Set<NormalizedRelayUrl> = indexerRelayList.flow.value.ifEmpty { DefaultIndexerRelayList }
|
||||
|
||||
override fun outboxHomeRelays(): Set<NormalizedRelayUrl> = nip65RelayList.allFlowNoDefaults.value + privateStorageRelayList.flow.value + localRelayList.flow.value
|
||||
|
||||
// searchRelayList.flow already applies the DefaultSearchRelayList fallback internally
|
||||
// (SearchRelayListState.normalizeSearchRelayListWithBackup), so no ifEmpty needed here.
|
||||
override fun searchRelays(): Set<NormalizedRelayUrl> = (trustedRelayList.flow.value + searchRelayList.flow.value).toSet()
|
||||
|
||||
override fun searchOnlyRelays(): Set<NormalizedRelayUrl> = searchRelayList.flow.value
|
||||
|
||||
override fun followPlusAllMineWithSearchRelays(): Set<NormalizedRelayUrl> = followPlusAllMineWithSearch.flow.value
|
||||
|
||||
override fun commonRelays(): Set<NormalizedRelayUrl> = followSharedOutboxesOrProxy.flow.value.ifEmpty { Constants.eventFinderRelays }
|
||||
|
||||
override fun cardHomeRelays(): Set<NormalizedRelayUrl> = homeRelays.flow.value
|
||||
|
||||
override fun trustProvider(): ServiceProviderTag? = trustProviderList.liveUserRankProvider.value
|
||||
|
||||
override fun followerCountProvider(): ServiceProviderTag? = trustProviderList.liveUserFollowerCount.value
|
||||
|
||||
override fun declaredFollowsByOutboxRelay(): Map<NormalizedRelayUrl, Set<HexKey>> = declaredFollowsPerOutboxRelay.value
|
||||
|
||||
val userMetadata = UserMetadataState(signer, cache, scope, settings)
|
||||
|
||||
// Per-account NIP-42 ALLOW/DENY overrides, warm-cached in memory so a relay AUTH challenge is
|
||||
@@ -955,6 +989,9 @@ class Account(
|
||||
*/
|
||||
fun applyBottomBarItems(items: List<BottomBarEntry>): Boolean = settings.changeBottomBarItems(items)
|
||||
|
||||
/** The drawer counterpart of [applyBottomBarItems] — same synchronous-apply, publish-after contract. */
|
||||
fun applyHiddenDrawerItems(items: Set<NavBarItem>): Boolean = settings.changeHiddenDrawerItems(items)
|
||||
|
||||
suspend fun toggleChatroomPin(room: ChatroomKey) {
|
||||
settings.toggleChatroomPin(room)
|
||||
sendNewAppSpecificData()
|
||||
@@ -3559,6 +3596,9 @@ class Account(
|
||||
refreshConcordChannelIndex()
|
||||
// A revision also bumps when a base-rotation rekey lands; adopt ours if present.
|
||||
runCatching { concord.drainConcordRekeys() }.onFailure { Log.w("Concord", "rekey drain failed", it) }
|
||||
// A promotion to staff delivers the Control Plane write key inside the Grant
|
||||
// itself (CORD-04 §3), so the fold that seats the role is also when it arrives.
|
||||
runCatching { concord.drainConcordStaffGrants() }.onFailure { Log.w("Concord", "staff grant drain failed", it) }
|
||||
// A rotation we were *excluded* from produces no rekey to drain, so it can only be
|
||||
// found by re-resolving the invite link we joined through. Rate-limited internally.
|
||||
runCatching { concord.recoverStrandedConcordCommunities() }.onFailure { Log.w("Concord", "stranded recovery failed", it) }
|
||||
|
||||
@@ -22,30 +22,43 @@ package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordModeration
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordReceive
|
||||
import com.vitorpamplona.amethyst.commons.actions.ConcordSubscriptionPlanner
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.concord.ConcordCommunitySession
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.ReplyMode
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.concordChannelLastReadRoute
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityList.withControlRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.HeldRoot
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ImagePointer
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.AuthorityResolver
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ChannelEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.ConcordPermissions
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.MetadataEntity
|
||||
import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.CommunityInvite
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteList
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListDocument
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEntry
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListTombstone
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteBundleStatus
|
||||
import com.vitorpamplona.quartz.concord.cord05Invites.InviteRelayDictionary
|
||||
import com.vitorpamplona.quartz.concord.crypto.ControlPlaneKeys
|
||||
import com.vitorpamplona.quartz.concord.crypto.GroupKey
|
||||
import com.vitorpamplona.quartz.concord.envelope.ConcordStreamEnvelope
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.anyRelayServed
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllPagesFromPool
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
@@ -55,6 +68,9 @@ import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/** Name of the default Concord community Admin role minted by "Make admin". */
|
||||
@@ -68,6 +84,15 @@ private const val CONCORD_ADMIN_ROLE = "Admin"
|
||||
*/
|
||||
private const val RECOVERY_CHECK_INTERVAL_MS = 15 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* How many recipients one Refounding will re-key. See `AccountConcordActions.boundRecipients`.
|
||||
*
|
||||
* 120 blobs ride in each kind-3303 chunk, so this is ~42 published events and ~5k NIP-44
|
||||
* encryptions at the ceiling — heavy but survivable on a phone, and far above any real community.
|
||||
* Raising it raises the cost of the attack it exists to bound, not the safety.
|
||||
*/
|
||||
private const val MAX_REFOUNDING_RECIPIENTS = 5_000
|
||||
|
||||
/**
|
||||
* Concord (encrypted communities) orchestration for an [Account]: join/create/
|
||||
* invite flows, channel messages/reactions/edits/typing, roles and moderation,
|
||||
@@ -138,6 +163,10 @@ class AccountConcordActions(
|
||||
ownerSalt = community.ownerSalt.toHexKey(),
|
||||
root = community.communityRoot.toHexKey(),
|
||||
rootEpoch = community.rootEpoch,
|
||||
// The creator is the founding staff member (CORD-02 §2): it keeps the write
|
||||
// secret and publishes only the derived pubkey to everyone else.
|
||||
controlPk = community.controlPkHex,
|
||||
controlRoot = community.controlRoot.toHexKey(),
|
||||
relays = relayUrls,
|
||||
name = name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
@@ -146,6 +175,138 @@ class AccountConcordActions(
|
||||
return community.communityIdHex
|
||||
}
|
||||
|
||||
// ---- CORD-05 Invite List (kind 13303) -------------------------------------
|
||||
|
||||
/**
|
||||
* This account's Invite List (kind 13303): the creator's private, self-encrypted record of every
|
||||
* link they minted (`token` + `signer_sk` per entry).
|
||||
*
|
||||
* Returns **null** when the list could not be read — no relay answered, or the signer refused
|
||||
* the decrypt — and an empty document only when the account genuinely has no list yet. Callers
|
||||
* must not conflate the two: republishing an "empty" list over this replaceable coordinate
|
||||
* destroys every `signer_sk` it failed to read, and those secrets cannot be regenerated.
|
||||
*
|
||||
* Read on the account's OUTBOX relays, never a community's: the coordinate is
|
||||
* (13303, me, "") — one list for the whole account — so scoping it per community would fork it
|
||||
* into divergent versions that the newest-wins rule then silently collapses.
|
||||
*
|
||||
* Fetched rather than read from [LocalCache] because nothing subscribes to 13303: it is
|
||||
* bookkeeping the user never sees, needed only at mint and at rotation.
|
||||
*/
|
||||
private suspend fun readConcordInviteList(): ConcordInviteListDocument? {
|
||||
val relays = account.outboxRelays.flow.value
|
||||
if (relays.isEmpty()) return null
|
||||
val filter = Filter(kinds = listOf(ConcordInviteListEvent.KIND), authors = listOf(account.signer.pubKey))
|
||||
// Terminal reasons, not just events: `fetchAll` returns an empty list both when a relay
|
||||
// served us and had nothing AND when nothing answered at all (cannot-connect, CLOSED, idle
|
||||
// timeout). Treating the second as "no list yet" is precisely how a read-merge-write wipes
|
||||
// the signer_sk of every link it failed to read, so the two must be told apart.
|
||||
val reasons = mutableMapOf<NormalizedRelayUrl, String>()
|
||||
val events =
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = relays.associateWith { listOf(filter) },
|
||||
doneOut = reasons,
|
||||
) { _, _ -> true }
|
||||
|
||||
val newest =
|
||||
events
|
||||
.mapNotNull { it.second as? ConcordInviteListEvent }
|
||||
// Filter by kind BEFORE picking the newest: taking the newest of anything and then
|
||||
// casting means one stray event at this coordinate reads as "unreadable" forever.
|
||||
.maxByOrNull { it.createdAt }
|
||||
?: return if (reasons.anyRelayServed()) {
|
||||
ConcordInviteListDocument.EMPTY // a relay answered and had nothing — safe to start one
|
||||
} else {
|
||||
null // nobody answered; we know nothing about what is published
|
||||
}
|
||||
return newest.decrypt(account.signer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges [patch] into the published Invite List and republishes it, returning whether it landed.
|
||||
*
|
||||
* Read-merge-write, and **aborts rather than overwriting** when the read fails: the list is
|
||||
* replaceable, so publishing a patch-only document over an unread list deletes every other
|
||||
* link's `signer_sk` — unrecoverable, and it strands every holder of those links at the next
|
||||
* rotation. A momentarily unreachable relay or a bunker signer that declines one decrypt is
|
||||
* enough to trigger that, which is exactly how the kind-13302 community list was once emptied.
|
||||
*/
|
||||
private suspend fun publishConcordInviteList(patch: ConcordInviteListDocument): Boolean {
|
||||
val publishTo = account.outboxRelays.flow.value
|
||||
if (publishTo.isEmpty()) return false
|
||||
val base =
|
||||
readConcordInviteList() ?: run {
|
||||
Log.w("Concord") { "Refusing to write the invite list: could not read the current one (would drop other links' signer_sk)" }
|
||||
return false
|
||||
}
|
||||
// publishAndConfirm, never publish: `INostrClient.publish` returns Unit — it queues the event
|
||||
// and never reports acceptance — so a `runCatching { publish(); true }` is true whenever
|
||||
// local signing worked, and every caller's "did the record land?" gate becomes decorative.
|
||||
return runCatching {
|
||||
account.client.publishAndConfirm(ConcordInviteListEvent.create(account.signer, ConcordInviteList.merge(base, patch), TimeUtils.now()), publishTo)
|
||||
}.onFailure { Log.w("Concord", "invite list publish failed", it) }.getOrDefault(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-posts every live link this account minted for [entry]'s community at its own coordinate,
|
||||
* carrying [entry]'s epoch (CORD-05). The kind-33301 bundle is addressable and authored by the
|
||||
* link signer, so this moves the link behind the same URL instead of orphaning it at a dead
|
||||
* epoch — which is the whole premise stranded recovery rests on.
|
||||
*
|
||||
* [entry] MUST be the post-rotation entry, passed in rather than re-read: the joined-list flow
|
||||
* decrypts asynchronously, so reading it straight after adopting a new root yields the OLD
|
||||
* epoch and would re-mint every link onto the epoch we just left.
|
||||
*
|
||||
* Each link is refreshed from its own CURRENT bundle, not rebuilt from scratch, so per-link
|
||||
* fields the bundle carries — expiry, channel grants, icon, label — survive the rotation. A
|
||||
* coordinate whose newest event is a revocation tombstone is left alone: re-posting a live
|
||||
* bundle over it would silently un-revoke the link.
|
||||
*/
|
||||
private suspend fun refreshConcordInviteLinks(entry: ConcordCommunityListEntry): Int {
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
if (relays.isEmpty()) return 0
|
||||
val list = readConcordInviteList() ?: return 0
|
||||
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
|
||||
val now = TimeUtils.now()
|
||||
|
||||
// An elapsed or retired link can no longer be joined; re-posting it would only resurrect a
|
||||
// dead URL at a live epoch.
|
||||
val links = list.entries.filter { it.communityId == entry.id && !it.isExpired(now) && it.token !in tombstoned }
|
||||
if (links.isEmpty()) return 0
|
||||
|
||||
// One REQ for every link's bundle rather than a round trip each. This runs inside the
|
||||
// user-visible Refounding, and a serial fetch per link makes a removal take time linear in
|
||||
// how many links the creator ever minted, each able to wait out its own idle timeout.
|
||||
val byAuthor = links.associateBy { it.signerPubKeyHex().lowercase() }
|
||||
val wraps = account.client.fetchAll(filters = relays.associateWith { listOf(ConcordActions.bundlesFilter(byAuthor.keys.toList())) })
|
||||
val wrapsByAuthor = wraps.groupBy { it.pubKey.lowercase() }
|
||||
|
||||
return coroutineScope {
|
||||
byAuthor
|
||||
.map { (author, link) ->
|
||||
async {
|
||||
runCatching {
|
||||
val token = link.token.hexToByteArray()
|
||||
// Classify per coordinate, never over the pooled set: one link's newer
|
||||
// revocation tombstone must not decide another link's status.
|
||||
val current = ConcordActions.classifyInvite(wrapsByAuthor[author].orEmpty(), token) as? InviteBundleStatus.Live ?: return@runCatching false
|
||||
val moved =
|
||||
current.invite.copy(
|
||||
communityRoot = entry.root,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
controlPk = entry.controlPk,
|
||||
relays = entry.relays,
|
||||
)
|
||||
// Confirmed: a link counted as moved but never stored is a link its
|
||||
// holders can no longer redeem, reported as a success.
|
||||
account.client.publishAndConfirm(ConcordActions.remintBundleAt(link.signerSk.hexToByteArray(), token, moved, now), relays)
|
||||
}.onFailure { Log.w("Concord", "invite refresh failed for ${entry.id}", it) }.getOrDefault(false)
|
||||
}
|
||||
}.awaitAll()
|
||||
.count { it }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a shareable invite link for a joined community and publish its
|
||||
* kind-33301 public bundle to the community relays. Returns the `…/invite/…`
|
||||
@@ -159,6 +320,21 @@ class AccountConcordActions(
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return null
|
||||
// CREATE_INVITE, and not while banned. This used to check only that we held the community,
|
||||
// which made minting the one moderation-free action in the app: a member the owner had just
|
||||
// banned could tap the invite button and hand out a working link to the community they were
|
||||
// removed from, and every account they invited arrived as a fresh un-banned npub.
|
||||
//
|
||||
// Note the bit is not otherwise enforced anywhere. The fold gates the INVITE_* Control
|
||||
// entities on CREATE_INVITE, but a link's bundle is a standalone kind-33301 published
|
||||
// OUTSIDE the Control Plane, so no fold ever sees it. This check is the only one there is.
|
||||
// The owner is proven by the community id (CORD-02), so they are read off the entry and can
|
||||
// mint before the session exists — the session is built asynchronously off the joined list,
|
||||
// and requiring it here would have made the owner's own invite button fail on a cold start.
|
||||
// Everyone else needs the folded roster, so no session means no invite.
|
||||
val session = account.concordSessions.sessionFor(communityId)
|
||||
val amOwner = entry.owner.equals(account.signer.pubKey, ignoreCase = true)
|
||||
if (!amOwner && (session == null || !isAuthorizedFor(session, ConcordPermissions.CREATE_INVITE))) return null
|
||||
val invite =
|
||||
ConcordActions.inviteFor(
|
||||
communityIdHex = entry.id,
|
||||
@@ -168,14 +344,110 @@ class AccountConcordActions(
|
||||
rootEpoch = entry.rootEpoch,
|
||||
name = entry.name,
|
||||
relays = entry.relays,
|
||||
// The joiner can never derive the Control Plane address, so the bundle carries
|
||||
// it (CORD-05 §1). Null on a legacy community, which has none to carry.
|
||||
controlPk = entry.controlPk,
|
||||
)
|
||||
val minted = ConcordActions.mintInviteLink(base, invite, TimeUtils.now(), entry.relays)
|
||||
|
||||
val publishTo = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
// Record the link BEFORE handing the URL out (CORD-05, kind 13303). A link whose `signer_sk`
|
||||
// was never stored can never be refreshed, so the next Refounding orphans it and everyone
|
||||
// holding it is stranded — with nothing to have warned them. Failing the mint is the honest
|
||||
// outcome; a stored entry for a link nobody received is harmless by comparison.
|
||||
if (!publishConcordInviteList(
|
||||
ConcordInviteListDocument(
|
||||
entries =
|
||||
listOf(
|
||||
ConcordInviteListEntry(
|
||||
token = minted.token.toHexKey(),
|
||||
signerSk = minted.linkSignerPrivKey.toHexKey(),
|
||||
communityId = entry.id,
|
||||
url = minted.url,
|
||||
createdAt = TimeUtils.now(),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
) {
|
||||
Log.w("Concord") { "Invite not minted for ${entry.id}: its link signer could not be recorded, so the link could never be refreshed" }
|
||||
return null
|
||||
}
|
||||
|
||||
if (publishTo.isNotEmpty()) account.client.publish(minted.bundleEvent, publishTo)
|
||||
return minted.url
|
||||
}
|
||||
|
||||
/**
|
||||
* Every link this account minted for [communityId] that is still live, newest first — the
|
||||
* backing list for the invite-links screen.
|
||||
*
|
||||
* Null means the list could not be read (no relay answered, or the signer refused the decrypt),
|
||||
* which the UI must show as an error rather than as "you have no links": telling a creator their
|
||||
* leaked link doesn't exist is worse than telling them we couldn't check.
|
||||
*
|
||||
* Retired tokens are filtered out here rather than rendered as dead rows — [ConcordInviteList]
|
||||
* already drops a tombstoned entry on merge, so a tombstoned entry only appears in the window
|
||||
* between our revoke and the next merge.
|
||||
*/
|
||||
suspend fun listConcordInviteLinks(communityId: String): List<ConcordInviteListEntry>? {
|
||||
val list = readConcordInviteList() ?: return null
|
||||
val tombstoned = list.tombstones.mapTo(HashSet()) { it.token }
|
||||
return list.entries
|
||||
.filter { it.communityId == communityId && it.token !in tombstoned }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Retires the link [token] (CORD-05 §2): publishes a `vsk=9` tombstone at its coordinate, then
|
||||
* records the retirement in the kind-13303 list. Returns false if the link could not be retired.
|
||||
*
|
||||
* No community permission is checked, deliberately. The coordinate is authored by the link
|
||||
* signer, whose secret only the creator holds, so revoking is an act on your own key rather than
|
||||
* on the community — and gating it on CREATE_INVITE would mean a demoted admin could no longer
|
||||
* retire the links they had already handed out, which is precisely when they most need to.
|
||||
*
|
||||
* The wire tombstone goes first and the list second. That is the inverse of minting and it is
|
||||
* deliberate: the entry holds the only copy of the `signer_sk` this needs, and a merge drops a
|
||||
* tombstoned token's entry terminally, so recording first and then failing to publish would
|
||||
* leave the link live with its signer gone and no way left to retire it. A failed list write is
|
||||
* recoverable — the link is already dead on the wire, and the refresh path re-mints only a
|
||||
* coordinate that still resolves Live.
|
||||
*/
|
||||
suspend fun revokeConcordInvite(
|
||||
communityId: String,
|
||||
token: String,
|
||||
): Boolean {
|
||||
if (!account.isWriteable()) return false
|
||||
val entry =
|
||||
account.concordChannelList.liveCommunities.value
|
||||
.firstOrNull { it.id == communityId } ?: return false
|
||||
val link =
|
||||
readConcordInviteList()?.entries?.firstOrNull { it.token == token && it.communityId == communityId }
|
||||
?: run {
|
||||
Log.w("Concord") { "Cannot revoke $token: it is not in this account's invite list, so its link signer is unknown" }
|
||||
return false
|
||||
}
|
||||
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }.ifEmpty { account.outboxRelays.flow.value }
|
||||
if (relays.isEmpty()) return false
|
||||
// Confirmed, not fire-and-forget. A `publish` that returns Unit would report success for a
|
||||
// tombstone no relay stored — and the list write below would then drop this entry on merge,
|
||||
// destroying the only `signer_sk` that could ever retire the link while the link stays live.
|
||||
val published =
|
||||
runCatching {
|
||||
account.client.publishAndConfirm(ConcordActions.revokeBundleAt(link.signerSk.hexToByteArray(), TimeUtils.now()), relays)
|
||||
}.onFailure { Log.w("Concord", "invite revocation failed for $communityId", it) }.getOrDefault(false)
|
||||
if (!published) return false
|
||||
|
||||
if (!publishConcordInviteList(ConcordInviteListDocument(tombstones = listOf(ConcordInviteListTombstone(token = token, communityId = communityId))))) {
|
||||
// The link is already dead on the wire, so this is bookkeeping we can retry rather than a
|
||||
// failed revocation. Reported as success for exactly that reason.
|
||||
Log.w("Concord") { "Revoked $token on the wire but could not tombstone it in the invite list; a later revoke will record it" }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Drop a joined Concord community from the private kind-13302 list by its id. */
|
||||
suspend fun leaveConcordCommunity(communityId: String) = account.sendMyPublicAndPrivateOutbox(account.concordChannelList.unfollow(communityId))
|
||||
|
||||
@@ -238,6 +510,42 @@ class AccountConcordActions(
|
||||
return ConcordInviteResult.Joined(bundle.communityId)
|
||||
}
|
||||
|
||||
// Refuse a link that readmits us after we were removed. A Refounding re-mints every
|
||||
// outstanding link onto the new root (CORD-05), and an ex-member keeps the URL and its
|
||||
// unlock token forever — so without this the rotation meant to expel them hands them the new
|
||||
// keys instead. `recoverStrandedConcordCommunities` has always been ban-gated; this is the
|
||||
// other door into the same room.
|
||||
//
|
||||
// Fails CLOSED on an unreadable plane: the banlist is only knowable once the bundle yields
|
||||
// the root, and no verdict means no join. Two things make that safe to insist on rather than
|
||||
// a way to brick valid invites:
|
||||
//
|
||||
// - the plane is fetched over the SAME relays that just served the bundle, not the relay
|
||||
// list inside the bundle alone, which can be stale (a moved relay, a link minted before a
|
||||
// relay change) and would otherwise refuse a community we can plainly reach;
|
||||
// - it is PAGED, because a single REQ is truncated at the relay's per-filter cap. A missing
|
||||
// older ban edition fails the gate open — it re-admits the very account it exists to
|
||||
// refuse — so the one direction we must not economise on is completeness.
|
||||
val joinKeys =
|
||||
ConcordActions.controlPlaneKeys(
|
||||
communityRoot = bundle.communityRoot.hexToByteArray(),
|
||||
communityId = bundle.communityId.hexToByteArray(),
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
controlPk = bundle.controlPk,
|
||||
)
|
||||
// Union, not `ifEmpty`: the relays that served the bundle are known-good for this community,
|
||||
// and the bundle's own list is the one that goes stale.
|
||||
val joinRelays = bundle.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) } + relays
|
||||
val planeWraps = mutableListOf<Event>()
|
||||
account.client.fetchAllPagesFromPool(
|
||||
filters = joinRelays.associateWith { listOf(ConcordActions.planeFilter(joinKeys.address)) },
|
||||
) { event, _ -> planeWraps.add(event) }
|
||||
val joinEditions = ConcordActions.controlEditions(planeWraps, joinKeys)
|
||||
if (joinEditions.isEmpty()) return ConcordInviteResult.NotReachable
|
||||
if (AuthorityResolver.resolve(joinEditions, bundle.owner).isBanned(account.signer.pubKey)) {
|
||||
return ConcordInviteResult.Banned
|
||||
}
|
||||
|
||||
val entry =
|
||||
ConcordCommunityListEntry(
|
||||
id = bundle.communityId,
|
||||
@@ -245,6 +553,9 @@ class AccountConcordActions(
|
||||
ownerSalt = bundle.ownerSalt,
|
||||
root = bundle.communityRoot,
|
||||
rootEpoch = bundle.rootEpoch,
|
||||
// Read access to the Control Plane, never write (CORD-05 §1). Absent = the
|
||||
// community is still pre-split, so we fold it at the legacy address.
|
||||
controlPk = bundle.controlPk,
|
||||
relays = bundle.relays,
|
||||
name = bundle.name,
|
||||
addedAt = TimeUtils.now() * 1000,
|
||||
@@ -412,7 +723,17 @@ class AccountConcordActions(
|
||||
channelIdHex: String,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val entry = account.concordSessions.sessionFor(communityId)?.entry ?: return
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return
|
||||
// A ban hides every message we send, so continuing to announce that we are typing them is
|
||||
// both noise and a contradiction of what the ban told the room. Filtered on the receive side
|
||||
// too (ConcordCommunitySession.ingestTyping) — a malicious client would keep sending.
|
||||
if (session.state.value
|
||||
?.authority
|
||||
?.isBanned(account.signer.pubKey) == true
|
||||
) {
|
||||
return
|
||||
}
|
||||
val entry = session.entry
|
||||
val channelKey = ConcordActions.publicChannel(entry.root.hexToByteArray(), channelIdHex.hexToByteArray(), entry.rootEpoch)
|
||||
val wrap = ConcordActions.buildChannelTyping(account.signer, channelKey, channelIdHex, entry.rootEpoch, TimeUtils.now())
|
||||
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
|
||||
@@ -451,6 +772,67 @@ class AccountConcordActions(
|
||||
// every client's AuthorityResolver, so a call by someone who doesn't outrank the
|
||||
// target is simply dropped on fold. Owner-authored calls always take effect.
|
||||
|
||||
/**
|
||||
* The Control Plane keys for a moderation write, or null when this account cannot
|
||||
* publish there: on a split epoch only `control_root` holders can mint a wrap that
|
||||
* verifies at the plane's address (CORD-02 §2), and wrapping without the secret
|
||||
* throws rather than missigning. Rank and key possession can diverge — a freshly
|
||||
* promoted staffer writes only once their `control_wrap` is adopted (CORD-04 §3),
|
||||
* and the UI gates on rank — so every moderation verb no-ops through this check
|
||||
* instead of crashing on a rank-gated action.
|
||||
*/
|
||||
private fun controlKeysForWrite(session: ConcordCommunitySession): ControlPlaneKeys? {
|
||||
val cp = session.controlPlaneKeys()
|
||||
if (!cp.canWrite) {
|
||||
Log.w("Concord") { "Control write refused for ${session.entry.id}: control_root not held at epoch ${session.entry.rootEpoch} (CORD-02 §2)" }
|
||||
return null
|
||||
}
|
||||
return cp
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account may take the action guarded by [bit] in [session] — and, when [target] is
|
||||
* given, take it *against that member* (CORD-04 §3's rank rule, "equal cannot act on equal").
|
||||
*
|
||||
* Every moderation verb below funnels through this. It used to live only in the composables that
|
||||
* drew the buttons, which failed three ways: the screens tested `effectivePermissions`, which
|
||||
* ignores the banlist, so a banned staffer still saw the controls; a verb reached from anywhere
|
||||
* else (desktop, `amy`, a new screen) inherited no check at all; and holding `control_root` —
|
||||
* a spam gate, never authority (CORD-02 §5) — was the only thing actually being enforced.
|
||||
*
|
||||
* Fails **closed**, with one deliberate exception: the owner is read from [ConcordCommunityListEntry]
|
||||
* rather than from the fold, because the community id proves them (CORD-02) and they must stay able
|
||||
* to moderate before their Control Plane has finished folding — or through a fold a rogue has
|
||||
* damaged. Everyone else needs a resolved roster, so an unfolded community grants nobody else
|
||||
* anything.
|
||||
*/
|
||||
private fun isAuthorizedFor(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): Boolean {
|
||||
val me = account.signer.pubKey
|
||||
if (session.entry.owner.equals(me, ignoreCase = true)) return true
|
||||
val authority = session.state.value?.authority ?: return false
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone and would let a
|
||||
// banned staffer keep acting for as long as they hold the key.
|
||||
val allowed = if (target == null) authority.hasPermission(me, bit) else authority.canActOn(me, target, bit)
|
||||
if (!allowed) {
|
||||
Log.w("Concord") { "Refusing a Concord action in ${session.entry.id}: not authorized for bit $bit${target?.let { " on $it" } ?: ""} (CORD-04 §3)" }
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
/** [controlKeysForWrite] gated by [isAuthorizedFor] — the standing check and the key check together. */
|
||||
private fun controlKeysForAction(
|
||||
session: ConcordCommunitySession,
|
||||
bit: Int,
|
||||
target: HexKey? = null,
|
||||
): ControlPlaneKeys? {
|
||||
if (!isAuthorizedFor(session, bit, target)) return null
|
||||
return controlKeysForWrite(session)
|
||||
}
|
||||
|
||||
/** Grant [member] exactly [roleIds] (empty list revokes their roles). */
|
||||
suspend fun grantConcordRole(
|
||||
communityId: String,
|
||||
@@ -459,7 +841,23 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, roleIds, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
// A Grant that first makes its member staff must deliver the control_root in the same
|
||||
// edition (CORD-04 §3) — grantWithStaffDelivery attaches the pairwise wrap when the
|
||||
// roles carry a Control-writing bit and we hold the secret to hand over.
|
||||
val wrap =
|
||||
ConcordModeration.grantWithStaffDelivery(
|
||||
actor = account.signer,
|
||||
controlPlane = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
member = member,
|
||||
roleIds = roleIds,
|
||||
current = session.controlEditions(),
|
||||
createdAt = TimeUtils.now(),
|
||||
owner = session.entry.owner,
|
||||
controlRoot = session.entry.controlRoot?.hexToByteArray(),
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -494,11 +892,11 @@ class AccountConcordActions(
|
||||
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
|
||||
if (author == account.signer.pubKey) return null
|
||||
val communityId = channel.channelId.communityId
|
||||
val state =
|
||||
account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.state
|
||||
?.value ?: return null
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return null
|
||||
val state = session.state.value ?: return null
|
||||
// Rank alone isn't enough on a split epoch: the Grant edition takes the control_root
|
||||
// (CORD-02 §2), so don't offer an action the verb would refuse.
|
||||
if (!session.controlPlaneKeys().canWrite) return null
|
||||
if (state.authority.isOwner(author) || !state.authority.isOwner(account.signer.pubKey)) return null
|
||||
val adminRoleId =
|
||||
state.roles.entries
|
||||
@@ -515,7 +913,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = session.controlPlaneKey()
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
|
||||
val existing =
|
||||
session.state.value
|
||||
@@ -530,7 +928,22 @@ class AccountConcordActions(
|
||||
roleId.toHexKey()
|
||||
}
|
||||
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, listOf(roleIdHex), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
// Admin carries every management bit, so this Grant makes its member staff: it must
|
||||
// deliver the control_root alongside the rank (CORD-04 §3), or the new admin holds
|
||||
// authority it cannot publish under.
|
||||
val grantWrap =
|
||||
ConcordModeration.grantWithStaffDelivery(
|
||||
actor = account.signer,
|
||||
controlPlane = cp,
|
||||
communityId = communityId.hexToByteArray(),
|
||||
member = member,
|
||||
roleIds = listOf(roleIdHex),
|
||||
current = session.controlEditions(),
|
||||
createdAt = TimeUtils.now(),
|
||||
owner = session.entry.owner,
|
||||
controlRoot = session.entry.controlRoot?.hexToByteArray(),
|
||||
epoch = session.entry.rootEpoch,
|
||||
)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -542,7 +955,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_ROLES, member) ?: return false
|
||||
val grantWrap = ConcordModeration.grant(account.signer, cp, communityId.hexToByteArray(), member, emptyList(), session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, grantWrap)
|
||||
return true
|
||||
}
|
||||
@@ -568,12 +982,11 @@ class AccountConcordActions(
|
||||
val author = note.author?.pubkeyHex ?: note.event?.pubKey ?: return null
|
||||
if (author == account.signer.pubKey) return null
|
||||
val communityId = channel.channelId.communityId
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(communityId)
|
||||
?.state
|
||||
?.value
|
||||
?.authority ?: return null
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return null
|
||||
val authority = session.state.value?.authority ?: return null
|
||||
// Rank alone isn't enough on a split epoch: the banlist edition takes the control_root
|
||||
// (CORD-02 §2), so don't offer an action the verb would refuse.
|
||||
if (!session.controlPlaneKeys().canWrite) return null
|
||||
if (authority.isOwner(author)) return null
|
||||
// The owner short-circuits rather than going through canActOn: canActOn starts at
|
||||
// hasPermission, which is false while banned, and a rogue BAN holder *can* currently put
|
||||
@@ -590,7 +1003,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -602,7 +1016,8 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val wrap = ConcordModeration.unban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.BAN, member) ?: return false
|
||||
val wrap = ConcordModeration.unban(account.signer, cp, communityId.hexToByteArray(), member, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -632,16 +1047,32 @@ class AccountConcordActions(
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
val state = session.state.value ?: return false
|
||||
val authority = state.authority
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.BAN)
|
||||
// hasPermission, not effectivePermissions: a Refounding is the hardest action in the protocol
|
||||
// and this guard used to ignore the banlist, so a banned BAN-holder could launch one from the
|
||||
// shipping app. Honest receivers refuse such a rotation (drainConcordRekeys checks the same
|
||||
// ban-aware predicate), but that is a race against banlist propagation, not a check.
|
||||
val iCanBan = authority.isOwner(account.signer.pubKey) || authority.hasPermission(account.signer.pubKey, ConcordPermissions.BAN)
|
||||
if (!iCanBan) return false
|
||||
val removedLower = removed.mapTo(HashSet()) { it.lowercase() }
|
||||
if (removedLower.isEmpty() || removedLower.any { authority.isOwner(it) }) return false
|
||||
// Removal is the hardest form of a ban, so it takes the same rank rule (CORD-04 §3): an admin
|
||||
// cannot Refound a peer admin out of the community any more than they could ban one. The owner
|
||||
// short-circuits, as everywhere else, because canActOn starts at hasPermission.
|
||||
if (!authority.isOwner(account.signer.pubKey) &&
|
||||
removedLower.any { !authority.canActOn(account.signer.pubKey, it, ConcordPermissions.BAN) }
|
||||
) {
|
||||
return false
|
||||
}
|
||||
// A Refounding writes the current plane (the pre-rotation bans) and the new one (the
|
||||
// compaction), so on a split epoch it takes the current control_root (CORD-02 §2). A
|
||||
// rank-qualified refounder whose secret hasn't arrived yet must wait for re-delivery.
|
||||
val cp = controlKeysForWrite(session) ?: return false
|
||||
|
||||
// 1. Ban the removed members on the current Control Plane so the compacted snapshot —
|
||||
// and thus the new epoch — carries the ban. publishConcordWrap folds it in locally
|
||||
// first, so each subsequent edition chains onto the updated banlist head.
|
||||
for (target in removedLower) {
|
||||
val banWrap = ConcordModeration.ban(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val banWrap = ConcordModeration.ban(account.signer, cp, communityId.hexToByteArray(), target, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, banWrap)
|
||||
}
|
||||
|
||||
@@ -662,22 +1093,34 @@ class AccountConcordActions(
|
||||
.apply {
|
||||
removeAll(removedLower)
|
||||
removeAll(authority.bannedMembers())
|
||||
}.toList()
|
||||
}.let { candidates -> boundRecipients(candidates, authority) }
|
||||
|
||||
// 3. Build the refounding: new root, compacted Control Plane, per-recipient rekey blobs.
|
||||
val entry = session.entry
|
||||
val newRoot = RandomInstance.bytes(32)
|
||||
// A fresh control_root is minted beside the new root at every Refounding (CORD-02 §2),
|
||||
// so a demoted staffer's retained secret dies with the epoch — and a legacy community
|
||||
// upgrades to the split as a side effect of its next ban (CORD-06 §3).
|
||||
val newControlRoot = RandomInstance.bytes(32)
|
||||
// The staff set the new secret goes to: the owner plus everyone holding a
|
||||
// Control-writing bit (CORD-04 §3). They get the 136-byte blob, every other
|
||||
// recipient the 104-byte one carrying the pubkey alone. (The builder mints a
|
||||
// blob per recipient, so staff who aren't recipients are simply never reached.)
|
||||
val staff = authority.staffMembers()
|
||||
val build =
|
||||
ConcordActions.buildRefounding(
|
||||
rotatorSigner = account.signer,
|
||||
communityId = communityId,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
newRoot = newRoot,
|
||||
newControlRoot = newControlRoot,
|
||||
rootEpoch = entry.rootEpoch,
|
||||
priorControlWraps = session.controlPlaneWraps(),
|
||||
priorControlKey = session.controlPlaneKey(),
|
||||
priorControlKeys = cp,
|
||||
recipientsXOnly = recipients,
|
||||
staffXOnly = staff,
|
||||
createdAt = TimeUtils.now(),
|
||||
ownerPubKey = entry.owner,
|
||||
)
|
||||
|
||||
// 4. Publish the compacted Control Plane (the new epoch's state) then the rekey blobs
|
||||
@@ -690,10 +1133,60 @@ class AccountConcordActions(
|
||||
|
||||
// 5. Adopt the new epoch ourselves. This rebuilds our session under the new root and
|
||||
// re-folds the compacted Control Plane (with the ban), dropping the removed members.
|
||||
adoptConcordRoot(entry, newRoot, build.newEpoch)
|
||||
val adopted = adoptConcordRoot(entry, newRoot, build.newEpoch, build.newControlKeys.address.hexToByteArray(), newControlRoot)
|
||||
|
||||
// 6. Move every link we minted to the new epoch. Without this the Refounding orphans them,
|
||||
// and a member it left out — no rekey blob, no message to miss — has no way back at all.
|
||||
// Uses the entry adoption just wrote: `liveCommunities` decrypts asynchronously, so
|
||||
// reading it here would hand us the epoch we just left and re-mint every link onto it.
|
||||
val moved = adopted?.let { refreshConcordInviteLinks(it) } ?: 0
|
||||
Log.i("Concord") { "Refounding ${entry.id}: refreshed $moved invite link(s) to epoch ${build.newEpoch}" }
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Caps the Refounding recipient set, keeping the members whose standing we can actually vouch
|
||||
* for when there are too many.
|
||||
*
|
||||
* `allMembers()` is the Guestbook ∪ `observedAuthors` ∪ the roster, and the first two are
|
||||
* unbounded and attacker-writable: a Guestbook Join is self-signed by any key at all, and every
|
||||
* author we decrypt is folded in by design (CORD-02 §5, "observably present"). So each throwaway
|
||||
* npub someone posts from, or simply announces, becomes one more mandatory blob in the next
|
||||
* Refounding — meaning the attack inflates the cost of its own remedy, and the remedy is the only
|
||||
* hard removal Concord has. See B4 in `docs/concord-soft-ban-audit.md`.
|
||||
*
|
||||
* The roster and the owner are kept unconditionally: they are owner-rooted, so they cannot be
|
||||
* padded from outside. The remainder fills the budget, and anything dropped is **logged rather
|
||||
* than silently truncated** — a dropped member is stranded on the dead epoch and their only way
|
||||
* back is a recovery path that needs to know it happened.
|
||||
*/
|
||||
private fun boundRecipients(
|
||||
candidates: Set<HexKey>,
|
||||
authority: AuthorityResolver,
|
||||
): List<HexKey> {
|
||||
if (candidates.size <= MAX_REFOUNDING_RECIPIENTS) return candidates.toList()
|
||||
|
||||
// The roster goes in whole even if it alone exceeds the budget: it is owner-rooted, so it
|
||||
// cannot be padded from outside, and dropping an admin to make room for a stranger inverts
|
||||
// the point of the cap.
|
||||
val vouched = authority.roleHolders() + authority.staffMembers()
|
||||
val kept = LinkedHashSet<HexKey>()
|
||||
candidates.filterTo(kept) { it in vouched }
|
||||
for (candidate in candidates) {
|
||||
if (kept.size >= MAX_REFOUNDING_RECIPIENTS) break
|
||||
kept.add(candidate)
|
||||
}
|
||||
val dropped = candidates.size - kept.size
|
||||
if (dropped > 0) {
|
||||
Log.w("Concord") {
|
||||
"Refounding recipient set trimmed to ${kept.size} of ${candidates.size} " +
|
||||
"(budget $MAX_REFOUNDING_RECIPIENTS, roster kept whole): $dropped member(s) will be " +
|
||||
"stranded on the prior epoch"
|
||||
}
|
||||
}
|
||||
return kept.toList()
|
||||
}
|
||||
|
||||
// Rotations we've already adopted ("communityId:epoch"), so a base-rekey wrap still buffered
|
||||
// in the pre-rebuild window (the session rebuild off `liveCommunities` is async) is not
|
||||
// adopted — and re-published — twice on successive revision ticks.
|
||||
@@ -710,31 +1203,18 @@ class AccountConcordActions(
|
||||
entry: ConcordCommunityListEntry,
|
||||
newRoot: ByteArray,
|
||||
newEpoch: Long,
|
||||
) {
|
||||
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return
|
||||
val held = (entry.heldRoots + HeldRoot(entry.rootEpoch, entry.root)).distinctBy { it.epoch }
|
||||
val next =
|
||||
ConcordCommunityListEntry(
|
||||
id = entry.id,
|
||||
owner = entry.owner,
|
||||
ownerSalt = entry.ownerSalt,
|
||||
root = newRoot.toHexKey(),
|
||||
rootEpoch = newEpoch,
|
||||
heldRoots = held,
|
||||
privateChannels = entry.privateChannels,
|
||||
relays = entry.relays,
|
||||
name = entry.name,
|
||||
addedAt = entry.addedAt,
|
||||
// The invite_ref anchor must survive a rotation, or the *next* Refounding we're left
|
||||
// out of would be unrecoverable.
|
||||
inviteRef = entry.inviteRef,
|
||||
excludedAtEpoch = entry.excludedAtEpoch,
|
||||
// Unknown keys another client wrote (Armada's list is `[k: string]: unknown`)
|
||||
// must survive our rotation write, or we delete their data on every rekey.
|
||||
residue = entry.residue,
|
||||
)
|
||||
newControlPk: ByteArray? = null,
|
||||
newControlRoot: ByteArray? = null,
|
||||
): ConcordCommunityListEntry? {
|
||||
if (!adoptedConcordRotations.add("${entry.id}:$newEpoch")) return null
|
||||
// The rewrite itself — banking the leaving epoch's address for the anti-rollback floor,
|
||||
// dropping stale control material on a legacy rotation, preserving invite_ref and residue —
|
||||
// is shared with `amy` in [ConcordReceive.withAdoptedRoot]. Only the persist + publish and
|
||||
// the Guestbook re-announce below are Android's.
|
||||
val next = ConcordReceive.withAdoptedRoot(entry, newRoot, newEpoch, newControlPk, newControlRoot)
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(next))
|
||||
announceConcordGuestbookJoin(next, inviteCreator = null, inviteLabel = null)
|
||||
return next
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -769,6 +1249,7 @@ class AccountConcordActions(
|
||||
wraps = wraps,
|
||||
baseRekey = session.nextBaseRekeyKey(),
|
||||
recipientSigner = account.signer,
|
||||
communityId = entry.id,
|
||||
priorRoot = entry.root.hexToByteArray(),
|
||||
rootEpoch = entry.rootEpoch,
|
||||
) ?: continue
|
||||
@@ -779,7 +1260,49 @@ class AccountConcordActions(
|
||||
// who has themselves been banned could still rotate the whole community.
|
||||
val authorized = authority.isOwner(received.rotator) || authority.hasPermission(received.rotator, ConcordPermissions.BAN)
|
||||
if (!authorized) continue
|
||||
adoptConcordRoot(entry, received.newRoot, received.newEpoch)
|
||||
val adopted = adoptConcordRoot(entry, received.newRoot, received.newEpoch, received.newControlPk, received.newControlRoot)
|
||||
|
||||
// Move our own links onto the epoch we just adopted. Rotating is not the only way to end
|
||||
// up on a new epoch — being re-keyed is the common one — and a link creator who is merely
|
||||
// re-keyed would otherwise leave every link they handed out pointing at the dead root,
|
||||
// which is exactly the orphaning this branch exists to stop. Stranded recovery reads the
|
||||
// bundle's epoch, so a link nobody re-mints is a member nobody can recover.
|
||||
adopted?.let { next ->
|
||||
val moved = refreshConcordInviteLinks(next)
|
||||
if (moved > 0) Log.i("Concord") { "Rekey ${next.id}: refreshed $moved invite link(s) to epoch ${received.newEpoch}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adopt a `control_root` delivered to us by a staff-making Grant (CORD-04 §3): the
|
||||
* promoting edition carries the secret in `control_wrap`, NIP-44-encrypted under the
|
||||
* granter↔member pairwise key, so promotion and key delivery are one signed edition
|
||||
* with nothing separate to watch an inbox for.
|
||||
*
|
||||
* Adoption is gated twice and fails closed both times. The secret is adopted only if
|
||||
* it derives to exactly the `control_pk` we already hold for the named epoch — a
|
||||
* garbage wrap is attributable griefing, nothing worse — and only from a Grant our own
|
||||
* fold honors, so a rogue cannot feed us a key by minting an edition nobody accepts.
|
||||
* The epoch check matters because compaction re-wraps a Grant head verbatim across
|
||||
* Refoundings, so a folded head can legitimately carry a wrap minted for a prior epoch.
|
||||
*
|
||||
* Idempotent: once the entry holds the secret there is nothing to adopt. Runs on the
|
||||
* revision tick, like the rekey drain.
|
||||
*/
|
||||
internal suspend fun drainConcordStaffGrants() {
|
||||
if (!account.isWriteable()) return
|
||||
for (session in account.concordSessions.sessions()) {
|
||||
val entry = session.entry
|
||||
val state = session.state.value ?: continue
|
||||
// The whole decision — are we staff, does a Grant carry a wrap, does it open, name our
|
||||
// epoch, and derive to the control_pk we hold — is shared with `amy` in
|
||||
// [ConcordReceive.deliveredControlRoot]. Only the persist + publish below is Android's.
|
||||
val delivered = ConcordReceive.deliveredControlRoot(entry, session.controlEditions(), state.authority, account.signer) ?: continue
|
||||
|
||||
account.sendMyPublicAndPrivateOutbox(
|
||||
account.concordChannelList.follow(entry.withControlRoot(delivered)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +1359,29 @@ class AccountConcordActions(
|
||||
// Only a live bundle recovers: an expired/revoked link is not a rotation we missed.
|
||||
val bundle = (ConcordActions.classifyInvite(wraps, parsed.fragment.token) as? InviteBundleStatus.Live)?.invite ?: continue
|
||||
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle) ?: continue
|
||||
// A removed member holds the link's unlock token forever, so without this the sweep
|
||||
// walks them straight back into the epoch they were rotated out of — see A2 in
|
||||
// docs/concord-soft-ban-audit.md. Read off the epoch we are LEAVING, which is the last
|
||||
// one whose Control Plane we can still fold.
|
||||
//
|
||||
// Fails CLOSED. `?.isBanned(..) == true` reads "not banned" for a session that does not
|
||||
// exist yet or whose first fold has not landed, and this sweep runs on the revision tick
|
||||
// — so a banned member's own client would have hit that window on cold start and
|
||||
// recovered itself, which is precisely the bypass this gate exists to stop. No verdict
|
||||
// means no recovery; the next sweep retries once the roster is known.
|
||||
val authority =
|
||||
account.concordSessions
|
||||
.sessionFor(entry.id)
|
||||
?.state
|
||||
?.value
|
||||
?.authority
|
||||
if (authority == null) {
|
||||
Log.i("Concord") { "Stranded-recovery check deferred for ${entry.id}: control plane not folded yet" }
|
||||
lastConcordRecoveryCheck.remove(entry.id)
|
||||
continue
|
||||
}
|
||||
val bannedHere = authority.isBanned(account.signer.pubKey)
|
||||
val merged = ConcordActions.recoverStranded(entry, bundle, bannedHere) ?: continue
|
||||
if (!adoptedConcordRotations.add("${entry.id}:${merged.rootEpoch}")) continue
|
||||
Log.i("Concord", "Stranded recovery: ${entry.id} ${entry.rootEpoch} -> ${merged.rootEpoch}")
|
||||
account.sendMyPublicAndPrivateOutbox(account.concordChannelList.follow(merged))
|
||||
@@ -859,8 +1404,9 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_METADATA) ?: return false
|
||||
val metadata = MetadataEntity(name = name, icon = icon, banner = banner, description = description, relays = relays)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, session.controlPlaneKey(), communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.editMetadata(account.signer, cp, communityId.hexToByteArray(), metadata, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -876,9 +1422,10 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
val channelId = RandomInstance.bytes(32)
|
||||
val channel = ChannelEntity(name = name.trim())
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelId, channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -891,6 +1438,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Carry the standing definition forward and change only the name. A ChannelEntity built from
|
||||
// scratch defaults `private` and `voice` to false, so renaming a private channel used to
|
||||
// publish an edition declaring it PUBLIC — and a voice channel became a text channel.
|
||||
@@ -900,7 +1448,7 @@ class AccountConcordActions(
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
@@ -913,6 +1461,7 @@ class AccountConcordActions(
|
||||
): Boolean {
|
||||
val session = account.concordSessions.sessionFor(communityId) ?: return false
|
||||
if (!account.isWriteable()) return false
|
||||
val cp = controlKeysForAction(session, ConcordPermissions.MANAGE_CHANNELS) ?: return false
|
||||
// Same as rename: preserve the standing flags so a tombstone does not also silently
|
||||
// reclassify the channel it retires.
|
||||
val standing =
|
||||
@@ -921,7 +1470,7 @@ class AccountConcordActions(
|
||||
?.get(channelIdHex)
|
||||
?.definition
|
||||
val channel = ChannelEntity(name = name.trim(), private = standing?.private ?: false, voice = standing?.voice ?: false, deleted = true)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, session.controlPlaneKey(), channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
val wrap = ConcordModeration.defineChannel(account.signer, cp, channelIdHex.hexToByteArray(), channel, session.controlEditions(), TimeUtils.now(), owner = session.entry.owner)
|
||||
publishConcordWrap(session.entry, wrap)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
@@ -501,6 +503,18 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
|
||||
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
|
||||
// Settings as hidden, which would leave no route back to the screen that hides rows.
|
||||
val sanitized = DrawerItemVisibility.sanitize(newItems)
|
||||
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
|
||||
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
|
||||
val navigation =
|
||||
AccountNavigationPreferences(
|
||||
MutableStateFlow(internalSettings.navigation.bottomBarItems),
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
),
|
||||
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
|
||||
navigation =
|
||||
AccountNavigationPreferencesInternal(
|
||||
navigation.bottomBarItems.value,
|
||||
navigation.hiddenDrawerItems.value.toNames(),
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
|
||||
if (navigation.bottomBarItems.value != newBottomBarItems) {
|
||||
navigation.bottomBarItems.tryEmit(newBottomBarItems)
|
||||
}
|
||||
|
||||
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
|
||||
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
|
||||
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
|
||||
@Stable
|
||||
class AccountNavigationPreferences(
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
|
||||
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
|
||||
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
||||
+9
@@ -170,6 +170,15 @@ class AccountNavigationPreferencesInternal(
|
||||
// favorite apps, and individual joined chats/groups). Defaulted so blobs
|
||||
// written before this field existed decode to the app's current defaults.
|
||||
var bottomBarItems: List<BottomBarEntry> = DefaultBottomBarEntries,
|
||||
// The drawer (side menu) rows the user switched off, as NavBarItem *names*.
|
||||
// Empty by default, which is what makes a newly shipped destination visible
|
||||
// to everyone without a migration — see DrawerItemVisibility.
|
||||
//
|
||||
// Stored as strings rather than the enum on purpose: an id written by a
|
||||
// newer client would fail the enum decoder and take the whole synced-settings
|
||||
// blob down with it, so unknown names are dropped on read instead (the same
|
||||
// approach AccountPoWPreferencesInternal.enabledCategories takes).
|
||||
var hiddenDrawerItems: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -395,7 +395,7 @@ class CachePruner(
|
||||
|
||||
if (noteEvent is ReportEvent) {
|
||||
noteEvent.reportedAuthor().forEach {
|
||||
cache.getUserIfExists(it.pubkey)?.reportsOrNull()?.let { reports ->
|
||||
cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports ->
|
||||
reports.removeReport(note)
|
||||
reports.removeReportNamingUser(note)
|
||||
}
|
||||
|
||||
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
|
||||
*/
|
||||
data object Expired : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The link opens, but this community's roster has banned us (CORD-04).
|
||||
*
|
||||
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
|
||||
* URL and its unlock token forever — so honouring the link alone would hand the new keys to the
|
||||
* very account the rotation expelled.
|
||||
*/
|
||||
data object Banned : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The bundle event was found but could not be opened with the link's token —
|
||||
* typically because it was minted by a newer/incompatible Concord client whose
|
||||
|
||||
@@ -29,7 +29,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
* needing the full [LocalCache] API.
|
||||
*/
|
||||
interface Dao {
|
||||
fun getOrCreateUser(hex: HexKey): User
|
||||
fun getOrCreateUser(pubkey: HexKey): User
|
||||
|
||||
fun getOrCreateNote(hex: HexKey): Note
|
||||
|
||||
|
||||
@@ -480,7 +480,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
@Volatile
|
||||
var lnurlEndpointResolver: LnurlEndpointResolver? = null
|
||||
|
||||
val relayHints = HintIndexer()
|
||||
override val relayHints = HintIndexer()
|
||||
|
||||
/**
|
||||
* Cashu mint URL directory, populated passively as
|
||||
@@ -680,18 +680,18 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
|
||||
fun observeLatestNote(filter: Filter) = observeNotes(filter).map { it.firstOrNull() }
|
||||
|
||||
fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
override fun checkGetOrCreateUser(key: String): User? = runCatching { getOrCreateUser(key) }.getOrNull()
|
||||
|
||||
fun load(keys: List<String>): List<User> = keys.mapNotNull(::checkGetOrCreateUser)
|
||||
|
||||
fun load(keys: Set<String>): Set<User> = keys.mapNotNullTo(mutableSetOf(), ::checkGetOrCreateUser)
|
||||
|
||||
override fun getOrCreateUser(hex: HexKey): User {
|
||||
require(isValidHex(key = hex)) { "$hex is not a valid hex" }
|
||||
override fun getOrCreateUser(pubkey: HexKey): User {
|
||||
require(isValidHex(key = pubkey)) { "$pubkey is not a valid hex" }
|
||||
// Pass `this` as the UserContext — User now resolves each pinned
|
||||
// addressable note (kind:10002 / 10050 / 10019) lazily on first
|
||||
// read, instead of all-or-nothing at construction time.
|
||||
return users.getOrCreate(hex) { User(it, userContext) }
|
||||
return users.getOrCreate(pubkey) { User(it, userContext) }
|
||||
}
|
||||
|
||||
/** [UserContext] bridge to this cache's addressable lookup. */
|
||||
@@ -1865,7 +1865,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
val new = consumeRegularEvent(event, relay, wasVerified)
|
||||
|
||||
if (new) {
|
||||
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubkey) }
|
||||
val authorsReported = event.reportedAuthor().mapNotNull { checkGetOrCreateUser(it.pubKey) }
|
||||
val eventsReported =
|
||||
event.reportedPost().mapNotNull { checkGetOrCreateNote(it.eventId) } +
|
||||
event.reportedAddresses().map { getOrCreateAddressableNote(it.address) }
|
||||
@@ -1886,7 +1886,7 @@ object LocalCache : ILocalCache, ICacheProvider, Dao {
|
||||
// report can `p`-tag an incidentally-mentioned third party with no type of its own,
|
||||
// and there is no threshold here to absorb that noise the way
|
||||
// `receivedReportsByAuthor`'s hide path does.
|
||||
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubkey }
|
||||
val explicitlyTyped = event.reportedAuthorsWithOwnType().mapTo(mutableSetOf()) { it.pubKey }
|
||||
authorsReported.forEach { author ->
|
||||
if (author.pubkeyHex in explicitlyTyped) author.reports().addReportNamingUser(note)
|
||||
}
|
||||
|
||||
+23
-1
@@ -62,6 +62,28 @@ class IndexerRelayListState(
|
||||
|
||||
suspend fun normalizeIndexerRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
/**
|
||||
* Same resolution as [normalizeIndexerRelayListWithBackup] but non-suspending, for use as the
|
||||
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
|
||||
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
|
||||
*
|
||||
* At login `indexerListNote.event` is usually still null and this resolves through
|
||||
* `settings.backupIndexRelayList`, restored from LocalPreferences — so an account with public
|
||||
* indexer relays gets its own relays immediately instead of the defaults.
|
||||
*/
|
||||
fun normalizeIndexerRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = indexListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultIndexerRelayList
|
||||
|
||||
/**
|
||||
* The account's indexer relays, **never empty** — [normalizeIndexerRelayListWithBackup]
|
||||
* substitutes [DefaultIndexerRelayList] both when there is no kind:10086 and when the
|
||||
* one we have decodes to zero relays. Callers assembling metadata / relay-list REQs read
|
||||
* this and can rely on getting a usable set; use [flowNoDefaults] instead to show or diff
|
||||
* what the user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeIndexerRelayListPrecached] rather than `emptySet()`, for the same
|
||||
* reason as the search list: `flowOn(IO)` makes the first real emission asynchronous, so an
|
||||
* `emptySet()` seed left a window where `.value` contradicted the contract above.
|
||||
*/
|
||||
val flow =
|
||||
getIndexerRelayListFlow()
|
||||
.map { normalizeIndexerRelayListWithBackup(it.note) }
|
||||
@@ -70,7 +92,7 @@ class IndexerRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
normalizeIndexerRelayListPrecached(indexerListNote),
|
||||
)
|
||||
|
||||
val flowNoDefaults =
|
||||
|
||||
+26
-1
@@ -62,6 +62,31 @@ class SearchRelayListState(
|
||||
|
||||
suspend fun normalizeSearchRelayListWithBackupNoDefaults(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet()
|
||||
|
||||
/**
|
||||
* Same resolution as [normalizeSearchRelayListWithBackup] but non-suspending, for use as the
|
||||
* [flow] seed. Reads the event's public tags plus any *already decrypted* private tags; it
|
||||
* never asks the signer, so it cannot block or hit a NIP-46 round trip.
|
||||
*
|
||||
* At login `searchListNote.event` is usually still null and this resolves through
|
||||
* `settings.backupSearchRelayList`, restored from LocalPreferences — so an account with public
|
||||
* search relays gets its own relays immediately instead of the defaults. Accounts whose relays
|
||||
* are exclusively private fall back to [DefaultSearchRelayList] until the first decrypt lands.
|
||||
*/
|
||||
fun normalizeSearchRelayListPrecached(note: Note): Set<NormalizedRelayUrl> = searchListEvent(note)?.let { decryptionCache.cachedRelays(it) }?.ifEmpty { null } ?: DefaultSearchRelayList
|
||||
|
||||
/**
|
||||
* The account's search relays, **never empty** — [normalizeSearchRelayListWithBackup]
|
||||
* substitutes [DefaultSearchRelayList] both when there is no kind:10007 and when the
|
||||
* one we have decodes to zero relays. Callers assembling NIP-50 REQs read this and can
|
||||
* rely on getting a usable set; use [flowNoDefaults] instead to show or diff what the
|
||||
* user actually configured.
|
||||
*
|
||||
* Seeded via [normalizeSearchRelayListPrecached] rather than `emptySet()`: `flowOn(IO)` means
|
||||
* the first real emission can never be synchronous with `stateIn`, so an `emptySet()` seed
|
||||
* left a window where `.value` contradicted the "never empty" contract above and search
|
||||
* silently queried nothing. That window is unbounded for a NIP-46 signer whose list has
|
||||
* private entries, since the first emission waits on a remote decrypt.
|
||||
*/
|
||||
val flow =
|
||||
getSearchRelayListFlow()
|
||||
.map { normalizeSearchRelayListWithBackup(it.note) }
|
||||
@@ -70,7 +95,7 @@ class SearchRelayListState(
|
||||
.stateIn(
|
||||
scope,
|
||||
SharingStarted.Eagerly,
|
||||
emptySet(),
|
||||
normalizeSearchRelayListPrecached(searchListNote),
|
||||
)
|
||||
|
||||
val flowNoDefaults =
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+26
-1
@@ -57,7 +57,27 @@ class DataStoreNappletStorage(
|
||||
key: String,
|
||||
value: String,
|
||||
) {
|
||||
dataStore.edit { it[keyOf(coordinate, key)] = value }
|
||||
dataStore.edit { preferences ->
|
||||
val prefix = prefixOf(coordinate)
|
||||
val target = keyOf(coordinate, key)
|
||||
val currentBytes =
|
||||
preferences
|
||||
.asMap()
|
||||
.entries
|
||||
.asSequence()
|
||||
.filter { it.key.name.startsWith(prefix) }
|
||||
.sumOf { (storedKey, storedValue) ->
|
||||
storedKey.name
|
||||
.removePrefix(prefix)
|
||||
.encodeToByteArray()
|
||||
.size +
|
||||
((storedValue as? String)?.encodeToByteArray()?.size ?: 0)
|
||||
}
|
||||
val replacedBytes = key.encodeToByteArray().size + (preferences[target]?.encodeToByteArray()?.size ?: 0)
|
||||
val proposedBytes = currentBytes - replacedBytes + key.encodeToByteArray().size + value.encodeToByteArray().size
|
||||
require(proposedBytes <= MAX_STORAGE_BYTES) { "Napplet storage quota exceeded." }
|
||||
preferences[target] = value
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun remove(
|
||||
@@ -87,4 +107,9 @@ class DataStoreNappletStorage(
|
||||
coordinate: String,
|
||||
key: String,
|
||||
) = stringPreferencesKey(prefixOf(coordinate) + key)
|
||||
|
||||
companion object {
|
||||
/** NAP-STORAGE's recommended per-napplet UTF-8 quota. */
|
||||
const val MAX_STORAGE_BYTES = 512 * 1024
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.napplethost.NappletIpc
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -58,6 +59,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* The trust boundary's main-process endpoint. The untrusted `:napplet` process binds this
|
||||
@@ -93,7 +95,11 @@ class NappletBrokerService : Service() {
|
||||
|
||||
// Live relay subscriptions, keyed by the applet's subId. The account comes per-open from the
|
||||
// requesting surface's launch token, so a surface's REQs always target the account it acts as.
|
||||
private val liveSubscriptions = NappletLiveSubscriptions()
|
||||
private val liveSubscriptions = NappletLiveSubscriptions(scope)
|
||||
|
||||
// NAP-RESOURCE cancellation is keyed by the trusted launch token plus the caller's request id.
|
||||
// Cancelling removes the job before it can emit a late terminal envelope to the sandbox.
|
||||
private val resourceRequests = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// The app-wide inc pub/sub bus: routes inc.emit between live napplet sessions as inc.event pushes.
|
||||
private val incBus = NappletIncBus { replyTo, payload -> push(replyTo, payload) }
|
||||
@@ -117,7 +123,7 @@ class NappletBrokerService : Service() {
|
||||
|
||||
override fun onDestroy() {
|
||||
liveSubscriptions.closeAll()
|
||||
identityWatch.stop()
|
||||
identityWatch.stopAll()
|
||||
// Every applet/browser surface has unbound, so the "session" the user granted for is over.
|
||||
// The ledger and the broker cache are now app-wide singletons that outlive this service, so
|
||||
// their in-memory session grants have to be dropped explicitly here — that keeps the lifetime
|
||||
@@ -280,38 +286,57 @@ class NappletBrokerService : Service() {
|
||||
// Resolve the launch token to the trusted identity + declared set. The sandbox never states
|
||||
// its own coordinate, so a compromised :napplet process can only ever act as the napplet it
|
||||
// was launched as (it holds only its own token). An unknown token = no session; refuse.
|
||||
val session = NappletLaunchRegistry.resolve(data.getString(NappletIpc.KEY_LAUNCH_TOKEN))
|
||||
val launchToken = data.getString(NappletIpc.KEY_LAUNCH_TOKEN)
|
||||
val session = NappletLaunchRegistry.resolve(launchToken)
|
||||
if (session == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("Unknown napplet session.")))
|
||||
return true
|
||||
}
|
||||
val identity = session.identity
|
||||
val declared = session.declared
|
||||
val resourceRequestKey = "$launchToken\u0000$requestId"
|
||||
if (requestType == "resource.cancel") {
|
||||
resourceRequests.remove(resourceRequestKey)?.cancel()
|
||||
return true
|
||||
}
|
||||
val tracksResourceRequest = requestType == "resource.bytes" || requestType == "resource.bytesMany"
|
||||
|
||||
scope.launch {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> reply(replyTo, requestId, outcome.payload)
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.WatchIdentity -> identityWatch.start(session.accountPubKey) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.UnwatchIdentity -> identityWatch.stop()
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
val requestJob =
|
||||
scope.launch(start = if (tracksResourceRequest) CoroutineStart.LAZY else CoroutineStart.DEFAULT) {
|
||||
// The shared, host-agnostic router owns decode → broker → encode and the subscribe-vs-reply
|
||||
// decision (it stays wire-identical with the future desktop host). This service only supplies
|
||||
// the broker, the Messenger transport, and the live relay subscription each Outcome implies.
|
||||
// The launch token decides whose key signs — not the active account. A surface opened by
|
||||
// one account can never be handed another's signer, even while it stays open across a switch.
|
||||
val broker = brokerFor(session.accountPubKey)
|
||||
if (broker == null) {
|
||||
reply(replyTo, requestId, NappletProtocolJson.encodeResponse(requestType, NappletResponse.Failed("That account is no longer signed in.")))
|
||||
return@launch
|
||||
}
|
||||
when (val outcome = NappletRequestRouter.route(broker, identity, declared, payload)) {
|
||||
is NappletRequestRouter.Outcome.Ignore -> {}
|
||||
is NappletRequestRouter.Outcome.Reply -> {
|
||||
reply(replyTo, requestId, outcome.payload)
|
||||
// NAP-IDENTITY has no watch/unwatch request. Once the consent-gated startup
|
||||
// snapshot succeeds, the runtime owns identity.changed delivery for this
|
||||
// trusted launch token until the broker service closes.
|
||||
if (requestType == "identity.getPublicKey" && outcome.response is NappletResponse.PublicKey && launchToken != null) {
|
||||
identityWatch.start(launchToken, session.accountPubKey) { push(replyTo, it) }
|
||||
}
|
||||
}
|
||||
is NappletRequestRouter.Outcome.OpenSubscription ->
|
||||
liveSubscriptions.open(outcome.subId, outcome.filters, accountFor(session.accountPubKey)) { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.CloseSubscription -> liveSubscriptions.close(outcome.subId)
|
||||
is NappletRequestRouter.Outcome.Push -> outcome.payloads.forEach { push(replyTo, it) }
|
||||
is NappletRequestRouter.Outcome.SubscribeInc -> incBus.subscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.UnsubscribeInc -> incBus.unsubscribe(replyTo, outcome.topic)
|
||||
is NappletRequestRouter.Outcome.EmitInc -> incBus.emit(replyTo, identity.coordinate, outcome.topic, outcome.payloadRaw)
|
||||
}
|
||||
}
|
||||
if (tracksResourceRequest) {
|
||||
resourceRequests.put(resourceRequestKey, requestJob)?.cancel()
|
||||
requestJob.invokeOnCompletion { resourceRequests.remove(resourceRequestKey, requestJob) }
|
||||
requestJob.start()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
@StringRes
|
||||
fun NappletCapability.labelRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay
|
||||
@@ -45,7 +44,6 @@ fun NappletCapability.labelRes(): Int =
|
||||
@StringRes
|
||||
fun NappletCapability.descriptionRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.SHELL -> R.string.napplet_cap_shell_desc
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
|
||||
|
||||
@@ -294,9 +294,10 @@ class NappletConsentSummary(
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
is NappletRequest.ResourceBytes -> context.getString(R.string.napplet_consent_resource)
|
||||
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
|
||||
context.getString(R.string.napplet_consent_resource)
|
||||
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
|
||||
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
|
||||
is NappletRequest.ShellSupports, is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Streams `identity.changed` pushes to an applet that registered `napplet.identity.onChanged`. It
|
||||
@@ -34,31 +35,35 @@ import kotlinx.coroutines.launch
|
||||
* value is dropped — the applet already has it via `getPublicKey`), encodes and pushes the new key
|
||||
* (or `""` when no account is signed in) to the caller-supplied sink.
|
||||
*
|
||||
* One watch at a time per host binding; [start] replaces any prior one. Reached only after the
|
||||
* router confirmed the applet declared the IDENTITY capability.
|
||||
* Watches are keyed by the trusted launch token so concurrent surfaces cannot replace each other's
|
||||
* streams. A watch starts only after that surface successfully obtains its public-key snapshot.
|
||||
*/
|
||||
class NappletIdentityWatch(
|
||||
private val scope: CoroutineScope,
|
||||
private val pubKey: (boundPubKey: String) -> Flow<String>,
|
||||
) {
|
||||
private var job: Job? = null
|
||||
private val jobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun start(
|
||||
watchId: String,
|
||||
boundPubKey: String,
|
||||
push: (String) -> Unit,
|
||||
) {
|
||||
stop()
|
||||
job =
|
||||
scope.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}
|
||||
jobs.computeIfAbsent(watchId) { id ->
|
||||
scope
|
||||
.launch {
|
||||
pubKey(boundPubKey)
|
||||
.distinctUntilChanged()
|
||||
.drop(1)
|
||||
.collect { push(NappletProtocolJson.encodeIdentityChanged(it)) }
|
||||
}.also { job ->
|
||||
job.invokeOnCompletion { jobs.remove(id, job) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
job?.cancel()
|
||||
job = null
|
||||
fun stopAll() {
|
||||
jobs.values.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ object NappletLaunchRegistry {
|
||||
accountPubKey: HexKey,
|
||||
): String {
|
||||
val token = ByteArray(32).also(secureRandom::nextBytes).toHexKey()
|
||||
sessions[token] = Session(identity, declared, accountPubKey)
|
||||
sessions[token] = Session(identity.copy(instanceId = token), declared, accountPubKey)
|
||||
return token
|
||||
}
|
||||
|
||||
|
||||
@@ -25,16 +25,20 @@ import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletArtifactPolicy
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag
|
||||
import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest
|
||||
import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Opens a napplet/nsite in the sandboxed [NappletHostActivity] (the `:napplet` process). Only
|
||||
@@ -49,20 +53,18 @@ object NappletLauncher {
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
) = launch(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
)
|
||||
) {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG) { "Refusing NIP-5D manifest that failed signature/author verification" }
|
||||
return
|
||||
}
|
||||
buildLaunchParams(context, manifest, authorPubKey, identifier)?.let { openHost(context, it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens any NIP-5A static site (nsite or napplet). [requires] is empty for a plain nsite —
|
||||
* the broker then refuses every capability, so the site renders as inert static content.
|
||||
* Opens a NIP-5A website from its already-resolved path data. NIP-5D napplets use the verified
|
||||
* manifest overload so raw callers cannot bypass signature/author validation.
|
||||
*/
|
||||
fun launch(
|
||||
context: Context,
|
||||
@@ -73,12 +75,26 @@ object NappletLauncher {
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
// nSites open as [HostProfile.WEBSITE]: a NIP-07 window.nostr provider + normal network. The
|
||||
// broker then grants the IDENTITY + RELAY capabilities NIP-07 needs (consent-gated), regardless
|
||||
// of the (empty) manifest `requires`. Napplets keep the default locked [HostProfile.NAPPLET].
|
||||
profile: HostProfile = HostProfile.NAPPLET,
|
||||
// Raw path data is accepted only for the legacy NIP-5A website profile. NIP-5D callers must
|
||||
// use the signature-checking manifest overload above.
|
||||
profile: HostProfile,
|
||||
) {
|
||||
if (profile != HostProfile.WEBSITE) {
|
||||
Log.w(TAG) { "Refusing raw NIP-5D launch without a verified manifest" }
|
||||
return
|
||||
}
|
||||
val params =
|
||||
runCatching { buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile) }
|
||||
.onFailure { Log.w(TAG, "Refusing invalid ${profile.name.lowercase()} launch", it) }
|
||||
.getOrNull()
|
||||
?: return
|
||||
openHost(context, params)
|
||||
}
|
||||
|
||||
private fun openHost(
|
||||
context: Context,
|
||||
params: Bundle,
|
||||
) {
|
||||
val params = buildLaunchParams(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
val intent =
|
||||
Intent(context, NappletHostActivity::class.java).apply {
|
||||
putExtras(params)
|
||||
@@ -105,6 +121,29 @@ object NappletLauncher {
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
require(profile == HostProfile.WEBSITE) { "NIP-5D launch parameters require a verified manifest." }
|
||||
return buildLaunchParamsTrusted(context, paths, servers, authorPubKey, identifier, aggregateHash, title, requires, profile)
|
||||
}
|
||||
|
||||
private fun buildLaunchParamsTrusted(
|
||||
context: Context,
|
||||
paths: List<PathTag>,
|
||||
servers: List<String>,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
aggregateHash: HexKey?,
|
||||
title: String,
|
||||
requires: List<String>,
|
||||
profile: HostProfile,
|
||||
): Bundle {
|
||||
val effectiveAggregateHash =
|
||||
if (profile == HostProfile.NAPPLET) {
|
||||
requireNotNull(NappletArtifactPolicy.verifiedAggregateHash(paths, aggregateHash)) {
|
||||
"NIP-5D requires one self-contained /index.html with a valid blob hash and matching aggregate."
|
||||
}
|
||||
} else {
|
||||
aggregateHash
|
||||
}
|
||||
val proxyPort = Amethyst.instance.torManager.activePortOrNull.value ?: -1
|
||||
|
||||
// Augment the manifest's servers with the author's published Blossom list (kind:10063), if
|
||||
@@ -118,7 +157,7 @@ object NappletLauncher {
|
||||
|
||||
// Mint the launch token in the (trusted) main process: the broker resolves the sandbox's
|
||||
// requests back to THIS identity + declared set, regardless of anything the sandbox sends.
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = aggregateHash)
|
||||
val identity = NappletIdentity(authorPubKey = authorPubKey, identifier = identifier, aggregateHash = effectiveAggregateHash)
|
||||
val declared = profile.declaredCapabilities(requires)
|
||||
// Bound to the account launching it, so the surface keeps signing as that account even if the
|
||||
// user switches while it is open (an embedded surface is rebuilt on a switch and re-mints).
|
||||
@@ -156,7 +195,7 @@ object NappletLauncher {
|
||||
putStringArrayList(NappletHostContract.EXTRA_SERVERS, ArrayList(allServers))
|
||||
putString(NappletHostContract.EXTRA_AUTHOR, authorPubKey)
|
||||
putString(NappletHostContract.EXTRA_IDENTIFIER, identifier)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, aggregateHash)
|
||||
putString(NappletHostContract.EXTRA_AGGREGATE_HASH, effectiveAggregateHash)
|
||||
putString(NappletHostContract.EXTRA_TITLE, title)
|
||||
putStringArrayList(NappletHostContract.EXTRA_REQUIRES, ArrayList(requires))
|
||||
putStringArrayList(NappletHostContract.EXTRA_CAP_LABELS, ArrayList(capLabels))
|
||||
@@ -170,4 +209,34 @@ object NappletLauncher {
|
||||
putString(NappletHostContract.EXTRA_WEBVIEW_PROFILE, NappletWebViewProfiles.current())
|
||||
}
|
||||
}
|
||||
|
||||
/** Signature-checking entry point for embedded NIP-5D surfaces. */
|
||||
fun buildLaunchParams(
|
||||
context: Context,
|
||||
manifest: NappletManifest,
|
||||
authorPubKey: HexKey,
|
||||
identifier: String,
|
||||
): Bundle? {
|
||||
val event = manifest as? Event
|
||||
if (event?.verify() != true || event.pubKey != authorPubKey) {
|
||||
Log.w(TAG) { "Refusing embedded NIP-5D manifest that failed signature/author verification" }
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
buildLaunchParamsTrusted(
|
||||
context = context,
|
||||
paths = manifest.paths(),
|
||||
servers = manifest.servers(),
|
||||
authorPubKey = authorPubKey,
|
||||
identifier = identifier,
|
||||
aggregateHash = manifest.declaredAggregateHash() ?: manifest.computeAggregateHash(),
|
||||
title = manifest.title() ?: identifier.ifBlank { "Napplet" },
|
||||
requires = manifest.requires(),
|
||||
profile = HostProfile.NAPPLET,
|
||||
)
|
||||
}.onFailure { Log.w(TAG, "Refusing invalid embedded NIP-5D launch", it) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private const val TAG = "NappletLauncher"
|
||||
}
|
||||
|
||||
+50
-6
@@ -27,6 +27,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
@@ -44,7 +48,9 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* signatures still came from the old one. [open] is reached only after the broker authorized the
|
||||
* subscription (RELAY consent).
|
||||
*/
|
||||
class NappletLiveSubscriptions {
|
||||
class NappletLiveSubscriptions(
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val liveSubs = ConcurrentHashMap<String, LiveSub>()
|
||||
private val liveSeq = AtomicInteger(0)
|
||||
|
||||
@@ -53,6 +59,20 @@ class NappletLiveSubscriptions {
|
||||
val client: INostrClient,
|
||||
) {
|
||||
val eoseSent = AtomicBoolean(false)
|
||||
val deliveries = Channel<Delivery>(Channel.UNLIMITED)
|
||||
var deliveryJob: Job? = null
|
||||
}
|
||||
|
||||
private sealed interface Delivery {
|
||||
data class RelayEvent(
|
||||
val event: Event,
|
||||
) : Delivery
|
||||
|
||||
data object Eose : Delivery
|
||||
|
||||
data class Closed(
|
||||
val reason: String,
|
||||
) : Delivery
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,15 +97,31 @@ class NappletLiveSubscriptions {
|
||||
// can't collide with the subscription it's replacing.
|
||||
val sub = LiveSub("napplet-$nappletSubId-${liveSeq.incrementAndGet()}", account.client)
|
||||
liveSubs[nappletSubId] = sub
|
||||
sub.deliveryJob =
|
||||
scope.launch {
|
||||
for (delivery in sub.deliveries) {
|
||||
if (liveSubs[nappletSubId] !== sub) break
|
||||
when (delivery) {
|
||||
is Delivery.RelayEvent ->
|
||||
NappletRelayCleartext.forDelivery(delivery.event, account.signer)?.let {
|
||||
push(NappletProtocolJson.encodeRelayEvent(nappletSubId, it))
|
||||
}
|
||||
Delivery.Eose -> push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
is Delivery.Closed -> push(NappletProtocolJson.encodeRelayClosed(nappletSubId, delivery.reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayEvent(nappletSubId, event))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.RelayEvent(event))
|
||||
}
|
||||
|
||||
// A subscription fans out to several relays; collapse their EOSEs into the single
|
||||
// relay.eose the SDK expects (fired when the first relay finishes its stored events).
|
||||
@@ -93,14 +129,16 @@ class NappletLiveSubscriptions {
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
if (sub.eoseSent.compareAndSet(false, true)) push(NappletProtocolJson.encodeRelayEose(nappletSubId))
|
||||
if (sub.eoseSent.compareAndSet(false, true)) sub.deliveries.trySend(Delivery.Eose)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
message: String,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) = push(NappletProtocolJson.encodeRelayClosed(nappletSubId, message))
|
||||
) {
|
||||
sub.deliveries.trySend(Delivery.Closed(message))
|
||||
}
|
||||
}
|
||||
|
||||
runCatching { sub.client.subscribe(sub.clientSubId, relays.associateWith { filters }, listener) }
|
||||
@@ -109,12 +147,18 @@ class NappletLiveSubscriptions {
|
||||
/** Stops the live subscription for [nappletSubId], unsubscribing from the client that opened it. */
|
||||
fun close(nappletSubId: String) {
|
||||
val sub = liveSubs.remove(nappletSubId) ?: return
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
|
||||
/** Tears down every open subscription (service teardown). */
|
||||
fun closeAll() {
|
||||
liveSubs.values.forEach { sub -> runCatching { sub.client.unsubscribe(sub.clientSubId) } }
|
||||
liveSubs.values.forEach { sub ->
|
||||
sub.deliveries.close()
|
||||
sub.deliveryJob?.cancel()
|
||||
runCatching { sub.client.unsubscribe(sub.clientSubId) }
|
||||
}
|
||||
liveSubs.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip44Encryption.Nip44v2
|
||||
|
||||
/** NAP-RELAY read boundary: encrypted event content is decrypted or withheld, never exposed. */
|
||||
internal object NappletRelayCleartext {
|
||||
suspend fun forDelivery(
|
||||
event: Event,
|
||||
signer: NostrSigner,
|
||||
): Event? = forDelivery(event, signer.pubKey, signer::decrypt)
|
||||
|
||||
internal suspend fun forDelivery(
|
||||
event: Event,
|
||||
userPubKey: HexKey,
|
||||
decrypt: suspend (String, HexKey) -> String,
|
||||
): Event? {
|
||||
if (!isEncrypted(event)) return event
|
||||
|
||||
val peer =
|
||||
when {
|
||||
event.pubKey == userPubKey -> event.recipientPubKey()
|
||||
event.isAddressedTo(userPubKey) -> event.pubKey
|
||||
else -> null
|
||||
} ?: return null
|
||||
val cleartext = runCatching { decrypt(event.content, peer) }.getOrNull() ?: return null
|
||||
|
||||
// NAP-RELAY defines a decrypted read projection. Retain the relay event's identity and
|
||||
// signature fields so callers can still correlate it, while making clear that this object
|
||||
// must never be republished as a signed event after its content projection has changed.
|
||||
return Event(event.id, event.pubKey, event.createdAt, event.kind, event.tags, cleartext, event.sig)
|
||||
}
|
||||
|
||||
internal fun isEncrypted(event: Event): Boolean =
|
||||
event is PrivateDmEvent ||
|
||||
EncryptedInfo.isNIP04(event.content) ||
|
||||
isNip44V2(event.content)
|
||||
|
||||
private fun isNip44V2(content: String): Boolean =
|
||||
content.length >= MIN_NIP44_V2_LENGTH &&
|
||||
runCatching { Nip44v2.EncryptedInfo.decodePayload(content) }.isSuccess
|
||||
|
||||
private fun Event.recipientPubKey(): HexKey? =
|
||||
tags.firstNotNullOfOrNull { tag ->
|
||||
tag.getOrNull(1)?.takeIf { tag.getOrNull(0) == "p" }
|
||||
}
|
||||
|
||||
private fun Event.isAddressedTo(pubKey: HexKey): Boolean = tags.any { tag -> tag.getOrNull(0) == "p" && tag.getOrNull(1) == pubKey }
|
||||
|
||||
private const val MIN_NIP44_V2_LENGTH = 132
|
||||
}
|
||||
+3
-1
@@ -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) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+276
-59
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
|
||||
import android.util.Base64
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -63,41 +81,100 @@ class NappletResourceFetcher(
|
||||
private val account: Account,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
|
||||
/** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource? =
|
||||
): NappletResourceResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
|
||||
when {
|
||||
url.startsWith("data:") -> decodeDataUrl(url)
|
||||
url.startsWith("https://") -> {
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (!r.isSuccessful) return@withContext null
|
||||
val body = r.body.bytes()
|
||||
val type = r.header("Content-Type") ?: "application/octet-stream"
|
||||
NappletResource(body, type)
|
||||
}
|
||||
}.getOrNull()
|
||||
url.startsWith("nostr:") ->
|
||||
resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
|
||||
url.startsWith("https://") || url.startsWith("blossom:") -> {
|
||||
// Route like the applet's own page: locked napplets stay on Tor. The derived
|
||||
// client removes ambient cookies/auth and validates DNS before every hop.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
|
||||
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
|
||||
}
|
||||
url.startsWith("blossom:") -> fetchBlossom(url, client)
|
||||
url.startsWith("nostr:") -> resolveNostr(url)
|
||||
else -> null
|
||||
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.cache(null)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.removeHeader("Cookie")
|
||||
.removeHeader("Proxy-Authorization")
|
||||
.build(),
|
||||
)
|
||||
}.build()
|
||||
|
||||
private suspend fun fetchHttps(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
|
||||
repeat(MAX_REDIRECTS + 1) { hop ->
|
||||
try {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(current)
|
||||
.get()
|
||||
.build(),
|
||||
).await()
|
||||
.use { response ->
|
||||
if (response.isRedirect) {
|
||||
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
|
||||
current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
|
||||
return@repeat
|
||||
}
|
||||
if (response.code == 404) return failure(ERROR_NOT_FOUND)
|
||||
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
|
||||
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
|
||||
return classify(body)
|
||||
}
|
||||
} catch (e: BlockedResourceException) {
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
}
|
||||
|
||||
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
|
||||
|
||||
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
|
||||
|
||||
/**
|
||||
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
|
||||
* carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to
|
||||
@@ -155,65 +232,205 @@ class NappletResourceFetcher(
|
||||
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
|
||||
* serves it.
|
||||
*/
|
||||
private fun fetchBlossom(
|
||||
private suspend fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResource? {
|
||||
val hash =
|
||||
url
|
||||
.removePrefix("blossom://")
|
||||
.removePrefix("blossom:")
|
||||
.substringBefore('/')
|
||||
.substringBefore('?')
|
||||
.trim()
|
||||
.lowercase()
|
||||
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
|
||||
): NappletResourceResult {
|
||||
if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
|
||||
if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
|
||||
val servers =
|
||||
account.blossomServers
|
||||
.getBlossomServersList()
|
||||
?.servers()
|
||||
.orEmpty()
|
||||
var sawHashMismatch = false
|
||||
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
|
||||
val bytes =
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(candidate)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (r.isSuccessful) r.body.bytes() else null
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
if (StaticSiteResolver.verify(bytes, hash)) {
|
||||
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream")
|
||||
when (val fetched = fetchHttps(candidate, client)) {
|
||||
is NappletResourceResult.Success -> {
|
||||
if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
|
||||
sawHashMismatch = true
|
||||
continue
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
|
||||
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
|
||||
}
|
||||
|
||||
private suspend fun Call.await(): Response =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWith(Result.success(response))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
|
||||
private fun decodeDataUrl(url: String): NappletResource? {
|
||||
private fun decodeDataUrl(url: String): NappletResourceResult {
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
|
||||
val meta = url.substring("data:".length, comma)
|
||||
val data = url.substring(comma + 1)
|
||||
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
|
||||
val isBase64 = meta.endsWith(";base64")
|
||||
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" }
|
||||
val declaredType =
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
|
||||
} else {
|
||||
URLDecoder.decode(data, "UTF-8").encodeToByteArray()
|
||||
runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
|
||||
}
|
||||
return NappletResource(bytes, contentType)
|
||||
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
return classify(bytes, declaredType)
|
||||
}
|
||||
|
||||
private fun classify(
|
||||
bytes: ByteArray,
|
||||
declaredType: String? = null,
|
||||
): NappletResourceResult {
|
||||
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
|
||||
val sniffed = sniffContentType(bytes)
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
}
|
||||
|
||||
private fun looksLikeSvg(bytes: ByteArray): Boolean {
|
||||
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
|
||||
return prefix.contains("<svg")
|
||||
}
|
||||
|
||||
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
|
||||
|
||||
private fun isPlainText(bytes: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
}.isSuccess && bytes.none { it == 0.toByte() }
|
||||
|
||||
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
|
||||
|
||||
private fun failure(
|
||||
error: String,
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = source.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_RESOURCE_BYTES) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
|
||||
|
||||
internal fun isPublicAddress(address: InetAddress): Boolean {
|
||||
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
|
||||
return false
|
||||
}
|
||||
val bytes = address.address
|
||||
if (bytes.size == 4) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val second = bytes[1].toInt() and 0xff
|
||||
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
|
||||
if (first == 0 || first >= 224) return false
|
||||
if (first == 100 && second in 64..127) return false
|
||||
if (first == 192 && second == 0) return false
|
||||
if (first == 198 && second in 18..19) return false
|
||||
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
|
||||
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
|
||||
} else if (bytes.size == 16) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
|
||||
if (
|
||||
first == 0x20 &&
|
||||
(bytes[1].toInt() and 0xff) == 0x01 &&
|
||||
(bytes[2].toInt() and 0xff) == 0x0d &&
|
||||
(bytes[3].toInt() and 0xff) == 0xb8
|
||||
) {
|
||||
return false // 2001:db8::/32 documentation range
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val FETCH_TIMEOUT_SECONDS = 30L
|
||||
private const val MAX_REDIRECTS = 5
|
||||
private const val MIME_PREFIX_BYTES = 8 * 1024
|
||||
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
|
||||
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
|
||||
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
|
||||
private const val ERROR_INVALID_REQUEST = "invalid-request"
|
||||
private const val ERROR_NOT_FOUND = "not-found"
|
||||
private const val ERROR_BLOCKED = "blocked-by-policy"
|
||||
private const val ERROR_TIMEOUT = "timeout"
|
||||
private const val ERROR_TOO_LARGE = "too-large"
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"audio/ogg",
|
||||
"video/mp4",
|
||||
)
|
||||
}
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+10
@@ -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))
|
||||
|
||||
+31
-5
@@ -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}" }
|
||||
|
||||
+145
-23
@@ -70,8 +70,16 @@ object NotificationUtils {
|
||||
const val PUBLIC_REPLY_ACTION = "com.vitorpamplona.amethyst.PUBLIC_REPLY_ACTION"
|
||||
const val MARMOT_REPLY_ACTION = "com.vitorpamplona.amethyst.MARMOT_REPLY_ACTION"
|
||||
const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION"
|
||||
const val DISMISS_ACTION = "com.vitorpamplona.amethyst.DISMISS_ACTION"
|
||||
const val KEY_REPLY_TEXT = "key_reply_text"
|
||||
const val KEY_NOTIFICATION_ID = "key_notification_id"
|
||||
|
||||
/**
|
||||
* Hex id of the event this notification was posted for, carried on every action
|
||||
* and on the delete intent so the receiver can mark it dismissed. Distinct from
|
||||
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
|
||||
*/
|
||||
const val KEY_EVENT_ID = "key_event_id"
|
||||
const val KEY_ACCOUNT_NPUB = "key_account_npub"
|
||||
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
|
||||
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
|
||||
@@ -82,16 +90,27 @@ object NotificationUtils {
|
||||
const val REPLY_GROUP_KEY_PREFIX = "com.vitorpamplona.amethyst.REPLY_NOTIFICATION"
|
||||
private const val REPLY_SUMMARY_ID_BASE = 0x50000
|
||||
|
||||
// Event ids the user has just read/dismissed in-app. The enrichment path
|
||||
// re-posts a notification as metadata arrives; without this guard a
|
||||
// notification the user already dismissed would be resurrected seconds later
|
||||
// when its author's kind:0 lands. Keyed by the event id string (not the
|
||||
// hashCode) so distinct events can't collide. Entries self-expire after a
|
||||
// window comfortably longer than the 25s enrichment window.
|
||||
/**
|
||||
* Every group key this object posts under starts with this. Used to tell our own
|
||||
* summaries apart from the ones the system creates when it force-groups us (those
|
||||
* live under `userId|pkg|g:Aggregate_…`), so the cleanup below never fights the
|
||||
* platform over a bundle it owns.
|
||||
*/
|
||||
private const val OWN_GROUP_PREFIX = "com.vitorpamplona.amethyst."
|
||||
|
||||
// Event ids the user is done with. The enrichment path re-posts a notification
|
||||
// as metadata arrives; without this guard a notification the user already got
|
||||
// rid of would be resurrected seconds later when its author's kind:0 lands, and
|
||||
// the enricher would go on holding a relay window and a wakelock open for it.
|
||||
// Every way a user can be done with a notification has to record here — reading
|
||||
// the event in-app, swiping the notification away, "mark as read", and replying
|
||||
// inline — or that path leaks the resurrection. Keyed by the event id string
|
||||
// (not the hashCode) so distinct events can't collide. Entries self-expire after
|
||||
// a window comfortably longer than the 25s enrichment window.
|
||||
private const val DISMISS_GUARD_MS = 90_000L
|
||||
private val recentlyDismissed = ConcurrentHashMap<String, Long>()
|
||||
|
||||
private fun markDismissed(eventId: String) {
|
||||
fun markDismissed(eventId: String) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
recentlyDismissed[eventId] = now + DISMISS_GUARD_MS
|
||||
if (recentlyDismissed.size > 256) {
|
||||
@@ -218,6 +237,7 @@ object NotificationUtils {
|
||||
.setPriority(category.priority())
|
||||
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
|
||||
.setGroup(groupKey)
|
||||
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(time * 1000)
|
||||
@@ -236,11 +256,11 @@ object NotificationUtils {
|
||||
}
|
||||
|
||||
if (inlineReply != null) {
|
||||
builder.addAction(publicReplyAction(applicationContext, notId, inlineReply))
|
||||
builder.addAction(publicReplyAction(applicationContext, notId, id, inlineReply))
|
||||
}
|
||||
|
||||
notify(notId, builder.build())
|
||||
sendGroupSummary(category, groupKey, summaryId, applicationContext)
|
||||
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -335,20 +355,21 @@ object NotificationUtils {
|
||||
.setPriority(category.priority())
|
||||
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
|
||||
.setGroup(groupKey)
|
||||
.setDeleteIntent(dismissIntent(applicationContext, notId, id))
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setWhen(time * 1000)
|
||||
|
||||
when (replyAction) {
|
||||
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, replyAction))
|
||||
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, replyAction))
|
||||
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, it)) }
|
||||
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
|
||||
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
|
||||
null -> publicInlineReply?.let { builder.addAction(publicReplyAction(applicationContext, notId, id, it)) }
|
||||
}
|
||||
|
||||
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId))
|
||||
if (addMarkRead) builder.addAction(markReadAction(applicationContext, notId, id))
|
||||
|
||||
notify(notId, builder.build())
|
||||
sendGroupSummary(category, groupKey, summaryId, applicationContext)
|
||||
sendGroupSummary(category, groupKey, summaryId, time, applicationContext)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -370,6 +391,38 @@ object NotificationUtils {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fires when the user swipes this notification away (or hits "Clear all"), so the
|
||||
* group summary can follow its last child out.
|
||||
*
|
||||
* We can't rely on the shade to take the summary with it: SystemUI hides a group
|
||||
* with a single child and renders that child at the top level
|
||||
* (`ShadeListBuilder.MIN_CHILDREN_FOR_GROUP`), and once promoted the child no
|
||||
* longer counts as "the only child in its group", so dismissing it leaves our
|
||||
* summary behind. A childless summary is not harmless — SystemUI promotes it into
|
||||
* the shade on its own, and the system force-groups it
|
||||
* (`GroupHelper.isGroupSummaryWithoutChildren`) into the same aggregate bundle we
|
||||
* post summaries to stay out of.
|
||||
*/
|
||||
private fun dismissIntent(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
): PendingIntent {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = DISMISS_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
}
|
||||
return PendingIntent.getBroadcast(
|
||||
applicationContext,
|
||||
notId + 2,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
|
||||
private fun replyRemoteInput(applicationContext: Context): RemoteInput =
|
||||
RemoteInput
|
||||
.Builder(KEY_REPLY_TEXT)
|
||||
@@ -399,12 +452,14 @@ object NotificationUtils {
|
||||
private fun dmReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
action: ReplyAction.Dm,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
this.action = REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
|
||||
putExtra(KEY_CHATROOM_MEMBERS, action.chatroomMembers)
|
||||
}
|
||||
@@ -414,12 +469,14 @@ object NotificationUtils {
|
||||
private fun marmotReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
action: ReplyAction.Marmot,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
this.action = MARMOT_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, action.accountNpub)
|
||||
putExtra(KEY_MARMOT_GROUP_ID, action.nostrGroupId)
|
||||
action.replyToInnerEventId?.let { putExtra(KEY_MARMOT_REPLY_TO_INNER_ID, it) }
|
||||
@@ -431,12 +488,14 @@ object NotificationUtils {
|
||||
private fun publicReplyAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
target: InlineReplyTarget,
|
||||
): NotificationCompat.Action {
|
||||
val intent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = PUBLIC_REPLY_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
putExtra(KEY_ACCOUNT_NPUB, target.accountNpub)
|
||||
putExtra(KEY_TARGET_EVENT_ID, target.targetEventId)
|
||||
}
|
||||
@@ -446,11 +505,13 @@ object NotificationUtils {
|
||||
private fun markReadAction(
|
||||
applicationContext: Context,
|
||||
notId: Int,
|
||||
eventId: String,
|
||||
): NotificationCompat.Action {
|
||||
val markReadIntent =
|
||||
Intent(applicationContext, NotificationReplyReceiver::class.java).apply {
|
||||
action = MARK_READ_ACTION
|
||||
putExtra(KEY_NOTIFICATION_ID, notId)
|
||||
putExtra(KEY_EVENT_ID, eventId)
|
||||
}
|
||||
val markReadPendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
@@ -549,16 +610,33 @@ object NotificationUtils {
|
||||
// Group summaries, dedup, dismissal
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Posts (or refreshes) our own summary for [groupKey].
|
||||
*
|
||||
* The summary goes up with the **first** child, not once a second one shows up.
|
||||
* Android 16 counts a group child whose summary is missing as ungrouped
|
||||
* (`GroupHelper.isGroupChildWithoutSummary`) and force-groups it into the
|
||||
* package's per-section aggregate bundle, next to every other ungrouped
|
||||
* notification in the same shade section. The bar is low: `config_autoGroupAtCount`
|
||||
* is 2, so a single summary-less child plus one other ungrouped notification is a
|
||||
* bundle. The always-on relay service is exactly that other notification — ongoing
|
||||
* and IMPORTANCE_LOW, it shares the Silent section with our two IMPORTANCE_LOW
|
||||
* kinds (reactions and reposts), so one lone repost would end up bundled with it.
|
||||
* The bundle then refuses to swipe away, because the system's aggregate summary
|
||||
* inherits FLAG_ONGOING_EVENT from any child carrying it — and the service
|
||||
* notification always does.
|
||||
*
|
||||
* Providing the summary from the start keeps the group ours and the system leaves
|
||||
* it alone. It costs nothing visually: the shade hides any group with fewer than
|
||||
* two children and shows the child on its own.
|
||||
*/
|
||||
private fun NotificationManager.sendGroupSummary(
|
||||
category: NotificationCategory,
|
||||
groupKey: String,
|
||||
summaryId: Int,
|
||||
time: Long,
|
||||
applicationContext: Context,
|
||||
) {
|
||||
val activeCount = activeNotifications.count { it.notification.group == groupKey && it.id != summaryId }
|
||||
|
||||
if (activeCount < 2) return
|
||||
|
||||
val summaryBuilder =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, category.channelId(applicationContext))
|
||||
@@ -566,8 +644,16 @@ object NotificationUtils {
|
||||
.setColor(category.color)
|
||||
.setGroup(groupKey)
|
||||
.setGroupSummary(true)
|
||||
// The children do the alerting. Without this the summary would buzz on
|
||||
// its own the moment it starts going up alongside the first child.
|
||||
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN)
|
||||
.setAutoCancel(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
// Pinned to the child's event time rather than left to default to "now".
|
||||
// The summary is re-posted on every one of the enrichment path's re-renders,
|
||||
// and a fresh timestamp each time would keep re-sorting the group in the
|
||||
// shade while the user is looking at it.
|
||||
.setWhen(time * 1000)
|
||||
.setStyle(
|
||||
NotificationCompat
|
||||
.InboxStyle()
|
||||
@@ -604,17 +690,53 @@ object NotificationUtils {
|
||||
// items), so bail out before touching anything when nothing is posted for it.
|
||||
if (activeNotifications.none { it.id == notId }) return
|
||||
|
||||
cancel(notId)
|
||||
cancelChildlessGroupSummaries()
|
||||
cancelAndPrune(notId)
|
||||
}
|
||||
|
||||
private fun NotificationManager.cancelChildlessGroupSummaries() {
|
||||
/**
|
||||
* Cancels [notId] and drops the group summary it leaves behind, if it was the last
|
||||
* child. Use this instead of a bare [NotificationManager.cancel] for anything we
|
||||
* posted through [postStandard] / [postConversation] — every one of those is a
|
||||
* group child with a summary above it.
|
||||
*/
|
||||
fun NotificationManager.cancelAndPrune(notId: Int) {
|
||||
cancel(notId)
|
||||
cancelChildlessGroupSummaries(alreadyGone = notId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops our summaries that no longer have any children.
|
||||
*
|
||||
* [alreadyGone] is the id of a notification cancelled moments ago: both
|
||||
* [NotificationManager.cancel] and [NotificationManager.notify] are asynchronous,
|
||||
* so [NotificationManager.activeNotifications] can still be listing it and would
|
||||
* otherwise keep its summary alive forever.
|
||||
*
|
||||
* Only summaries under [OWN_GROUP_PREFIX] are touched. The system's own aggregate
|
||||
* summaries also carry FLAG_GROUP_SUMMARY and show up in this list; cancelling one
|
||||
* only makes the platform rebuild it.
|
||||
*/
|
||||
fun NotificationManager.cancelChildlessGroupSummaries(alreadyGone: Int? = null) {
|
||||
val active: Array<StatusBarNotification> = activeNotifications
|
||||
|
||||
// Collect the groups that still have a child in one pass, then cancel the
|
||||
// summaries not in that set. Every child now ships with a summary, so this list
|
||||
// is about twice as long as it used to be and the pairwise scan it replaces grew
|
||||
// four-fold. Membership is decided by the summary flag rather than by comparing
|
||||
// ids, which is also what makes it correct when a child's id happens to equal the
|
||||
// summary's.
|
||||
val groupsWithChildren = HashSet<String>(active.size)
|
||||
for (child in active) {
|
||||
if (child.notification.flags and Notification.FLAG_GROUP_SUMMARY != 0) continue
|
||||
if (child.id == alreadyGone) continue
|
||||
child.notification.group?.let { groupsWithChildren.add(it) }
|
||||
}
|
||||
|
||||
for (summary in active) {
|
||||
if (summary.notification.flags and Notification.FLAG_GROUP_SUMMARY == 0) continue
|
||||
val group = summary.notification.group ?: continue
|
||||
val hasChildren = active.any { it.id != summary.id && it.notification.group == group }
|
||||
if (!hasChildren) cancel(summary.id)
|
||||
if (!group.startsWith(OWN_GROUP_PREFIX)) continue
|
||||
if (group !in groupsWithChildren) cancel(summary.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+29
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient
|
||||
import com.vitorpamplona.amethyst.commons.tor.TorRelaySettings
|
||||
import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation
|
||||
import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
@@ -49,6 +50,14 @@ class RelayProxyClientConnector(
|
||||
val torStatus: StateFlow<TorServiceStatus>,
|
||||
val client: INostrClient,
|
||||
val scope: CoroutineScope,
|
||||
/**
|
||||
* Called with the cause every time this connector *decides* to reconnect, so the
|
||||
* usage ledger can attribute relay churn without this class knowing where the
|
||||
* counters go. Causes are the `UsageKeys.TRIGGER_*` constants — see
|
||||
* [UsageKeys.relayTrigger] for why these are an upper bound rather than a count
|
||||
* of reconnects actually performed.
|
||||
*/
|
||||
val onTrigger: (String) -> Unit = {},
|
||||
) {
|
||||
data class RelayServiceInfra(
|
||||
val evaluator: TorRelayEvaluation,
|
||||
@@ -138,6 +147,9 @@ class RelayProxyClientConnector(
|
||||
infra.connectivity is ConnectivityStatus.Off -> {
|
||||
Log.d("ManageRelayServices") { "Connectivity Off: Pausing Relay Services ${infra.connectivity}" }
|
||||
if (client.isActive()) {
|
||||
// Counted inside the guard: the upstream combine() re-emits Off
|
||||
// repeatedly and only this branch does any work.
|
||||
onTrigger(UsageKeys.TRIGGER_OFF)
|
||||
client.disconnect()
|
||||
}
|
||||
if (infra.torStatus is TorServiceStatus.Active) {
|
||||
@@ -156,6 +168,7 @@ class RelayProxyClientConnector(
|
||||
}
|
||||
|
||||
// only calls this if the client is not active. Otherwise goes to the else below
|
||||
onTrigger(UsageKeys.TRIGGER_COLD_START)
|
||||
client.connect()
|
||||
lastNetworkId = networkId
|
||||
lastTorSettings = torSettings
|
||||
@@ -211,10 +224,26 @@ class RelayProxyClientConnector(
|
||||
Log.d("ManageRelayServices") {
|
||||
"Network identity changed ($previousNetworkId -> $networkId), rebuilding every relay connection"
|
||||
}
|
||||
// The expensive branch, and the one the churn investigation is
|
||||
// aimed at: a full teardown re-dials the whole pool and replays
|
||||
// every REQ.
|
||||
//
|
||||
// Only this cause is counted here, so a wifi<->cellular handoff —
|
||||
// which mints a new network handle AND rebuilds the OkHttp clients
|
||||
// off the metered bit — is booked as netid alone. relay.trigger.transport
|
||||
// therefore undercounts exactly the case one would most want it for;
|
||||
// read it as "transport changed WITHOUT the handle changing".
|
||||
onTrigger(UsageKeys.TRIGGER_NETID)
|
||||
// Full teardown: disconnect() drops the dead sockets AND clears each
|
||||
// relay's backoff, so the new network starts from a clean slate.
|
||||
client.reconnect(onlyIfChanged = false, ignoreRetryDelays = true)
|
||||
} else {
|
||||
// Non-exclusive: count each independently so the report shows
|
||||
// which combination fired.
|
||||
if (transportChanged) onTrigger(UsageKeys.TRIGGER_TRANSPORT)
|
||||
if (torPolicyChanged) onTrigger(UsageKeys.TRIGGER_TOR_POLICY)
|
||||
if (classificationChanged) onTrigger(UsageKeys.TRIGGER_CLASSIFICATION)
|
||||
|
||||
val freshStart = transportChanged || torPolicyChanged
|
||||
if (freshStart) {
|
||||
// The failures behind the current backoffs were measured against a
|
||||
|
||||
+1
-1
@@ -158,7 +158,7 @@ class BootRelayDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
/**
|
||||
* Amethyst variant of [SingleSubNoEoseCacheEoseManager] that restores single-account
|
||||
* attribution for [AccountScopedQuery] keys.
|
||||
*
|
||||
* The commons base is account-agnostic (attribution defaults to null) so it can live in
|
||||
* commonMain. Query states that carry an [Account] (home feed, channels, notifications, …)
|
||||
* subclass this so their single-account REQs still show up attributed in "Active Relay
|
||||
* Subscriptions".
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
*/
|
||||
abstract class AccountScopedSingleSubNoEoseCacheEoseManager<T>(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<T>,
|
||||
invalidateAfterEose: Boolean = false,
|
||||
) : SingleSubNoEoseCacheEoseManager<T>(client, allKeys, invalidateAfterEose) {
|
||||
override fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
}
|
||||
+1
-1
@@ -78,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -90,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -77,7 +77,7 @@ abstract class PerUserEoseManager<T>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class NotifyCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
newEose(TimeUtils.now(), relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
+2
-119
@@ -23,13 +23,13 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follow
|
||||
import com.vitorpamplona.amethyst.commons.defaults.Constants
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
|
||||
import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.pickRelaysToLoadUsers
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.mapOfSet
|
||||
|
||||
fun pickRelaysToLoadUsers(
|
||||
users: Set<User>,
|
||||
@@ -68,6 +68,7 @@ fun pickRelaysToLoadUsers(
|
||||
|
||||
return pickRelaysToLoadUsers(
|
||||
users,
|
||||
LocalCache.relayHints,
|
||||
indexRelays - cannotConnectRelays,
|
||||
homeRelays - cannotConnectRelays,
|
||||
searchRelays - cannotConnectRelays,
|
||||
@@ -77,121 +78,3 @@ fun pickRelaysToLoadUsers(
|
||||
hasTried,
|
||||
)
|
||||
}
|
||||
|
||||
fun pickRelaysToLoadUsers(
|
||||
users: Set<User>,
|
||||
indexRelays: Set<NormalizedRelayUrl>,
|
||||
homeRelays: Set<NormalizedRelayUrl>,
|
||||
searchRelays: Set<NormalizedRelayUrl>,
|
||||
connected: Set<NormalizedRelayUrl>,
|
||||
commonRelays: Set<NormalizedRelayUrl>,
|
||||
cannotConnectRelays: Set<NormalizedRelayUrl>,
|
||||
hasTried: EOSEAccountFast<User>,
|
||||
): Map<NormalizedRelayUrl, Set<HexKey>> =
|
||||
mapOfSet {
|
||||
users.forEachIndexed { _, key ->
|
||||
val tried = (hasTried.since(key)?.keys ?: emptySet()) + cannotConnectRelays
|
||||
|
||||
val outbox = key.authorRelayList()?.writeRelaysNorm()
|
||||
|
||||
if (!outbox.isNullOrEmpty()) {
|
||||
// If there is a home, get from it.
|
||||
|
||||
// if it tried all outbox relays, stop.
|
||||
// the UserWatch will take over from here.
|
||||
val leftToTry = (outbox - tried)
|
||||
leftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
// if not, tries hints first.
|
||||
val hints = key.allUsedRelays() + LocalCache.relayHints.hintsForKey(key.pubkeyHex)
|
||||
|
||||
val leftToTryOnHints = hints - tried
|
||||
|
||||
leftToTryOnHints.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
// if there are only a few hints, broadens the search
|
||||
if (leftToTryOnHints.size < 3) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val indexRelaysLeftToTry =
|
||||
(indexRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val homeRelaysLeftToTry =
|
||||
(homeRelays - tried).sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
if (indexRelaysLeftToTry.size >= 2) {
|
||||
add(indexRelaysLeftToTry[0], key.pubkeyHex)
|
||||
add(indexRelaysLeftToTry[1], key.pubkeyHex)
|
||||
} else if (indexRelaysLeftToTry.size == 1) {
|
||||
add(indexRelaysLeftToTry.first(), key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
indexRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
homeRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (indexRelaysLeftToTry.size < 2) {
|
||||
val searchRelaysLeftToTry = searchRelays - tried
|
||||
|
||||
searchRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
|
||||
val connectedRelaysLeftToTry =
|
||||
(connected - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
// picks one at random to avoid overloading these relays
|
||||
if (users.size > 300) {
|
||||
connectedRelaysLeftToTry.take(20).forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
} else {
|
||||
connectedRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
if (searchRelaysLeftToTry.size < 2) {
|
||||
// This creates a pre-deterministic order of the array such that
|
||||
// if this function is called twice, it returns the same arrays
|
||||
// which gets ignored by the relay client if we send it twice
|
||||
val allRelaysLeftToTry =
|
||||
(commonRelays - tried)
|
||||
.sortedBy { relay ->
|
||||
key.pubkeyHex.hashCode() xor relay.url.hashCode()
|
||||
}.take(100)
|
||||
|
||||
allRelaysLeftToTry.forEach {
|
||||
add(it, key.pubkeyHex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28PublicChats
|
||||
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.SingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.AccountScopedSingleSubNoEoseCacheEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderQueryState
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
@@ -37,7 +37,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
class ChannelLoaderSubAssembler(
|
||||
client: INostrClient,
|
||||
allKeys: () -> Set<ChannelFinderQueryState>,
|
||||
) : SingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
) : AccountScopedSingleSubNoEoseCacheEoseManager<ChannelFinderQueryState>(client, allKeys, invalidateAfterEose = true) {
|
||||
override fun updateFilter(keys: List<ChannelFinderQueryState>): List<RelayBasedFilter> = filterMissingChannelsById(keys)
|
||||
|
||||
override fun distinct(key: ChannelFinderQueryState) = key.channel
|
||||
|
||||
+20
-20
@@ -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,
|
||||
)
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+30
@@ -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
|
||||
+3
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@ class RelaySpeedLogger(
|
||||
|
||||
private val clientListener =
|
||||
object : RelayConnectionListener {
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
typealias EOSERelayList = com.vitorpamplona.amethyst.commons.relays.EOSERelayList
|
||||
typealias SincePerRelayMap = com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
|
||||
typealias MutableTime = com.vitorpamplona.amethyst.commons.relays.MutableTime
|
||||
typealias EOSEAccountFast<T> = com.vitorpamplona.amethyst.commons.relays.EOSEAccountFast<T>
|
||||
|
||||
open class EOSEByKey<U : Any>(
|
||||
cacheSize: Int = 200,
|
||||
@@ -113,60 +114,3 @@ open class EOSEAccountKey<U : Any>(
|
||||
time: Long,
|
||||
) = addOrUpdate(user, listCode, relayUrl, time)
|
||||
}
|
||||
|
||||
class EOSEAccountFast<T : Any>(
|
||||
cacheSize: Int = 20,
|
||||
) {
|
||||
private val users: LruCache<T, EOSERelayList> = LruCache(cacheSize)
|
||||
private val lock = Any()
|
||||
|
||||
fun addOrUpdate(
|
||||
user: T,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) {
|
||||
synchronized(lock) {
|
||||
val relayList = users[user]
|
||||
if (relayList == null) {
|
||||
val newList = EOSERelayList()
|
||||
users.put(user, newList)
|
||||
|
||||
newList.addOrUpdate(relayUrl, time)
|
||||
} else {
|
||||
relayList.addOrUpdate(relayUrl, time)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeEveryoneBut(list: Set<T>) {
|
||||
synchronized(lock) {
|
||||
users.snapshot().forEach {
|
||||
if (it.key !in list) {
|
||||
users.remove(it.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun removeDataFor(user: T) {
|
||||
synchronized(lock) {
|
||||
users.remove(user)
|
||||
}
|
||||
}
|
||||
|
||||
fun since(key: T): SincePerRelayMap? =
|
||||
synchronized(lock) {
|
||||
users[key]?.relayList?.toMutableMap()
|
||||
}
|
||||
|
||||
fun sinceRelaySet(key: T): Set<NormalizedRelayUrl>? =
|
||||
synchronized(lock) {
|
||||
users[key]?.relayList?.keys?.toSet()
|
||||
}
|
||||
|
||||
fun newEose(
|
||||
user: T,
|
||||
relayUrl: NormalizedRelayUrl,
|
||||
time: Long,
|
||||
) = addOrUpdate(user, relayUrl, time)
|
||||
}
|
||||
|
||||
+294
-5
@@ -20,10 +20,22 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import android.os.SystemClock
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposeOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.RelayConnectionListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Counts relay websocket traffic into the usage ledger. Frame sizes are
|
||||
@@ -31,29 +43,210 @@ import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
||||
* (relay JSON is ASCII-dominant), consistent with how RelayStats counts.
|
||||
* Excludes WS framing/compression; good enough for "which subsystem is
|
||||
* eating my data plan" comparisons.
|
||||
*
|
||||
* Beyond the byte totals this also carries the relay-churn diagnostics: a
|
||||
* per-verb split of those same bytes, a connection-lifetime histogram, and
|
||||
* per-relay failure/short-session counts. See
|
||||
* plans/2026-07-29-relay-churn-diagnostics.md for what each answers and how to
|
||||
* read them together.
|
||||
*
|
||||
* The verb split takes its name straight from the wire label the command already
|
||||
* knows (`Command.label()` / `Message.label()`), so `Σ verb == Σ msg` holds by
|
||||
* construction and a new subtype needs no change here.
|
||||
*/
|
||||
class RelayUsageListener(
|
||||
private val accountant: ResourceUsageAccountant,
|
||||
private val isMobile: () -> Boolean,
|
||||
private val isForeground: () -> Boolean,
|
||||
private val nowMs: () -> Long = { SystemClock.elapsedRealtime() },
|
||||
) : RelayConnectionListener {
|
||||
/**
|
||||
* Relay -> when its current session became ready. Touched from the per-relay
|
||||
* OkHttp dispatcher threads, hence concurrent. Keyed by [NormalizedRelayUrl] to
|
||||
* match the other per-relay caches (`RelayStats`, `RelayLimitsTracker`).
|
||||
*
|
||||
* Entries are consumed on disconnect. Three ways a session escapes the map
|
||||
* unrecorded, all counted rather than prevented — see [UsageKeys.RELAY_LIFE_OVERWRITE],
|
||||
* [UsageKeys.RELAY_LIFE_ORPHAN], and [UsageKeys.relayConnects] for process death.
|
||||
*/
|
||||
private val connectedSince = ConcurrentHashMap<NormalizedRelayUrl, Long>()
|
||||
|
||||
/**
|
||||
* Relay -> subscription ids currently open on this connection, so a REQ that
|
||||
* replaces an in-flight subscription can be told apart from one that opens a new
|
||||
* one. Cleared on disconnect, because the relay forgets them too — every REQ
|
||||
* after a reconnect is legitimately new.
|
||||
*
|
||||
* Bounded by the live subscription count per relay (tens), not by session length.
|
||||
*/
|
||||
private val openSubs = ConcurrentHashMap<NormalizedRelayUrl, MutableSet<String>>()
|
||||
|
||||
/**
|
||||
* Subscription id -> the purpose that opened it, for attributing inbound frames:
|
||||
* an EVENT names only its subscription, never why the client asked for it.
|
||||
*
|
||||
* Deliberately **not** [openSubs]. Sharing one map conflated two different
|
||||
* lifetimes and lost 41 % of the download to `unattributed` in the 2026-08-02
|
||||
* reading: a CLOSE removed the id, and a disconnect dropped the whole relay's
|
||||
* map, while frames already in flight were still arriving. "Is this subscription
|
||||
* open" and "what did this subscription belong to" answer different questions and
|
||||
* expire at different times — the second stays true after the first turns false.
|
||||
*
|
||||
* Keyed by subscription id alone, without the relay. The same id is used across
|
||||
* relays for one logical subscription, so the purpose is a property of the id;
|
||||
* this also means a frame arriving after a reconnect still attributes.
|
||||
*
|
||||
* Bounded by [MAX_TRACKED_SUBS] with wholesale eviction rather than an LRU: this
|
||||
* is a diagnostic on a hot path, ids are recycled steadily, and a rare reset that
|
||||
* sends a few frames to `unattributed` is cheaper than per-frame bookkeeping.
|
||||
* `unattributed` staying small is what says the bound is generous enough.
|
||||
*/
|
||||
private val subPurpose = ConcurrentHashMap<String, String>()
|
||||
|
||||
/**
|
||||
* Event ids delivered recently, as the first 64 bits of the id.
|
||||
*
|
||||
* Held as a Long rather than the 64-char hex, which saves the id strings
|
||||
* themselves — but a boxed `Long` plus its map node still costs ~56 bytes an
|
||||
* entry, so a full window is ~3 MB, not the few hundred KB the raw payload
|
||||
* suggests. Worth knowing before raising [MAX_TRACKED_EVENTS] on a 512 MB-class
|
||||
* device; an unboxed open-addressed `LongArray` would be ~400 KB if it ever needs
|
||||
* to grow. 64 bits makes a collision between distinct ids negligible where a
|
||||
* 32-bit hash would not be.
|
||||
*
|
||||
* The window only needs to span the fan-out, not the session: the same event
|
||||
* arrives from every relay carrying it within seconds, so near-term memory
|
||||
* catches the duplication this measures. Cleared wholesale at [MAX_TRACKED_EVENTS]
|
||||
* for the same reason [subPurpose] is — the alternative is per-frame LRU
|
||||
* bookkeeping on the hottest path in the app. A clear undercounts duplicates that
|
||||
* straddle it, so the ratio is a floor.
|
||||
*/
|
||||
private val recentEventIds = ConcurrentHashMap.newKeySet<Long>()
|
||||
|
||||
/**
|
||||
* Relay -> when this dial was decided, so the pre-request cost can be separated
|
||||
* from the handshake. Consumed by whichever of `onConnected`/`onCannotConnect`
|
||||
* ends the dial, so an entry never outlives the attempt that made it.
|
||||
*/
|
||||
private val dialStartedAt = ConcurrentHashMap<NormalizedRelayUrl, Long>()
|
||||
|
||||
/** Notice texts already logged, so one wording costs one line however often it arrives. */
|
||||
private val loggedNotices = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
override fun onSent(
|
||||
relay: IRelayClient,
|
||||
cmdStr: String,
|
||||
cmd: Command,
|
||||
success: Boolean,
|
||||
) {
|
||||
if (success) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = false), cmdStr.length.toLong())
|
||||
if (!success) return
|
||||
|
||||
val bytes = cmdStr.length.toLong()
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayMsg(mobile, fg, received = false), bytes)
|
||||
accountant.add(UsageKeys.relayVerb(cmd.label(), received = false, mobile, fg), bytes)
|
||||
|
||||
when (cmd) {
|
||||
is ReqCmd -> {
|
||||
accountant.add(UsageKeys.relaySubsSent(mobile, fg), 1)
|
||||
|
||||
val purpose = purposeOf(cmd)
|
||||
accountant.add(UsageKeys.relayPurposeSent(purpose), 1)
|
||||
accountant.add(UsageKeys.relayPurposeBytes(purpose), bytes)
|
||||
|
||||
if (subPurpose.size >= MAX_TRACKED_SUBS) subPurpose.clear()
|
||||
subPurpose[cmd.subId] = purpose
|
||||
|
||||
// Already open on this connection, so this REQ replaces a live
|
||||
// subscription rather than starting one.
|
||||
// computeIfAbsent, not getOrPut: the latter is get-then-put and two
|
||||
// threads racing the first REQ after a (re)connect would each build a
|
||||
// set, the losing put's subId vanishing with its orphaned set.
|
||||
// `sendIfConnected` deliberately calls listeners outside PoolRequests'
|
||||
// stripe lock, so same-relay concurrency here is by design.
|
||||
val known = openSubs.computeIfAbsent(relay.url) { ConcurrentHashMap.newKeySet() }
|
||||
if (!known.add(cmd.subId)) {
|
||||
accountant.add(UsageKeys.relaySubsResent(mobile, fg), 1)
|
||||
}
|
||||
// Within the window after this relay's connect, so almost certainly
|
||||
// part of syncState's replay rather than a user action. A time
|
||||
// window because nothing on this side marks a frame as belonging to
|
||||
// it; see UsageKeys.relaySubsReplay.
|
||||
val since = connectedSince[relay.url]
|
||||
if (since != null && nowMs() - since <= UsageKeys.REPLAY_WINDOW_MS) {
|
||||
accountant.add(UsageKeys.relaySubsReplay(mobile, fg), 1)
|
||||
}
|
||||
}
|
||||
is CloseCmd -> {
|
||||
accountant.add(UsageKeys.relaySubsClosed(mobile, fg), 1)
|
||||
openSubs[relay.url]?.remove(cmd.subId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayMsg(isMobile(), isForeground(), received = true), msgStr.length.toLong())
|
||||
val bytes = msgStr.length.toLong()
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayMsg(mobile, fg, received = true), bytes)
|
||||
accountant.add(UsageKeys.relayVerb(msg.label(), received = true, mobile, fg), bytes)
|
||||
|
||||
// Attribute the inbound side to whoever asked for it. Only frames that name a
|
||||
// subscription can be attributed; NOTICE and OK are relay-wide and are left out
|
||||
// rather than guessed at, which is why this does not reconcile to msg.rx.
|
||||
// Resolved once: the duplicate check below needs the same answer, and an
|
||||
// EVENT names only its subscription, never why the client asked for it.
|
||||
val purpose = subIdOf(msg)?.let { subPurpose[it] ?: UsageKeys.PURPOSE_UNATTRIBUTED }
|
||||
if (purpose != null) {
|
||||
accountant.add(UsageKeys.relayPurposeDown(purpose), bytes)
|
||||
accountant.add(UsageKeys.relayPurposeDownCount(purpose), 1)
|
||||
}
|
||||
|
||||
if (msg is EventMessage) {
|
||||
accountant.add(UsageKeys.relayEventsSeen(mobile, fg), 1)
|
||||
idPrefix(msg.event.id)?.let { key ->
|
||||
if (recentEventIds.size >= MAX_TRACKED_EVENTS) recentEventIds.clear()
|
||||
if (!recentEventIds.add(key)) {
|
||||
accountant.add(UsageKeys.relayEventsDup(mobile, fg), 1)
|
||||
accountant.add(UsageKeys.relayEventsDupBytes(mobile, fg), bytes)
|
||||
if (purpose != null) accountant.add(UsageKeys.relayPurposeDupBytes(purpose), bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A refused subscription arrives here and nowhere else: the NOTICE carries no
|
||||
// subscription id, so RelayReqRefusals (wired to CLOSED) never sees it.
|
||||
if (msg is NoticeMessage) {
|
||||
val reason = UsageKeys.noticeReason(msg.message)
|
||||
accountant.add(UsageKeys.relayNotice(reason), 1)
|
||||
if (reason == UsageKeys.NOTICE_UNCLASSIFIED) {
|
||||
// The counter alone cannot say whether an absent `toomanysubs` means no
|
||||
// refusals or an allowlist that misses how this relay words them.
|
||||
//
|
||||
// INFO, not DEBUG: a debug build defaults to LogLevel.INFO
|
||||
// (Amethyst.DEFAULT_LOG_LEVEL, with VERBOSE_LOGS off), so a DEBUG line
|
||||
// here is dropped before it reaches the sink and this said nothing at
|
||||
// all. Demote it once the allowlist stops needing evidence.
|
||||
//
|
||||
// One line per distinct wording rather than per frame: the ledger
|
||||
// already has the count, what is missing is the variety. Truncated and
|
||||
// capped because the text is server-controlled.
|
||||
if (loggedNotices.size < MAX_DISTINCT_NOTICES && loggedNotices.add(msg.message)) {
|
||||
Log.i(TAG) { "Unclassified NOTICE from ${relay.url.url}: ${msg.message.take(MAX_NOTICE_LOG)}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Dial attempts. Unlike [onCannotConnect] this really is one per dial. */
|
||||
override fun onConnecting(relay: IRelayClient) {
|
||||
accountant.add(UsageKeys.relayDials(isMobile(), isForeground()), 1)
|
||||
dialStartedAt[relay.url] = nowMs()
|
||||
}
|
||||
|
||||
// Every completed (re)connection paid a TCP+TLS handshake; high daily
|
||||
@@ -64,13 +257,109 @@ class RelayUsageListener(
|
||||
pingMillis: Int,
|
||||
compressed: Boolean,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnects(isMobile(), isForeground()), 1)
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
// Doubles as the lifetime histogram's denominator — one session begins here.
|
||||
accountant.add(UsageKeys.relayConnects(mobile, fg), 1)
|
||||
// Consumed unconditionally: the gap needs a handshake to subtract, but the
|
||||
// stamp has to go either way or a dial that cannot be timed leaks its entry.
|
||||
val dialedAt = dialStartedAt.remove(relay.url)
|
||||
// pingMillis is the transport's own handshake timing; <= 0 means it could
|
||||
// not measure it, and a fabricated 0 would be worse than no record.
|
||||
if (pingMillis > 0) {
|
||||
accountant.add(UsageKeys.relayHandshake(pingMillis.toLong(), mobile, fg), 1)
|
||||
if (dialedAt != null) {
|
||||
val gap = nowMs() - dialedAt - pingMillis
|
||||
if (gap >= 0) accountant.add(UsageKeys.relayDialGap(gap, mobile, fg), 1)
|
||||
}
|
||||
}
|
||||
|
||||
if (connectedSince.put(relay.url, nowMs()) != null) {
|
||||
accountant.add(UsageKeys.RELAY_LIFE_OVERWRITE, 1)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisconnected(relay: IRelayClient) {
|
||||
val mobile = isMobile()
|
||||
val fg = isForeground()
|
||||
accountant.add(UsageKeys.relayDisconnects(mobile, fg), 1)
|
||||
|
||||
openSubs.remove(relay.url)
|
||||
val startedAt = connectedSince.remove(relay.url)
|
||||
if (startedAt == null) {
|
||||
// A dial that never became ready, or a second disconnect for one session.
|
||||
accountant.add(UsageKeys.RELAY_LIFE_ORPHAN, 1)
|
||||
return
|
||||
}
|
||||
|
||||
val elapsed = (nowMs() - startedAt).coerceAtLeast(0)
|
||||
accountant.add(UsageKeys.relayLife(elapsed, mobile, fg), 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* The [SubPurpose] behind a REQ, from the first filter that declares one.
|
||||
*
|
||||
* One subscription id carries one purpose in practice, so the first is the
|
||||
* subscription's. A REQ whose filters are plain [com.vitorpamplona.quartz.nip01Core.relay.filters.Filter]s
|
||||
* predates #3832's tagging and is counted separately rather than guessed at.
|
||||
*/
|
||||
private fun purposeOf(cmd: ReqCmd): String =
|
||||
cmd.filters
|
||||
.firstNotNullOfOrNull { it.purposeOrNull() }
|
||||
?.let { UsageKeys.purposeKeyPart(it) }
|
||||
?: UsageKeys.PURPOSE_UNEXPLAINED
|
||||
|
||||
/**
|
||||
* The first 64 bits of an event id, or null if it is not a well-formed id.
|
||||
*
|
||||
* Parsed in place rather than `substring(0, 16).toULongOrNull(16)`: this runs on
|
||||
* every inbound EVENT frame, and the substring would be a String plus its backing
|
||||
* array per event, thrown away immediately.
|
||||
*/
|
||||
private fun idPrefix(id: String): Long? {
|
||||
if (id.length < 16) return null
|
||||
var acc = 0L
|
||||
for (i in 0 until 16) {
|
||||
val digit = Character.digit(id[i], 16)
|
||||
if (digit < 0) return null
|
||||
acc = (acc shl 4) or digit.toLong()
|
||||
}
|
||||
return acc
|
||||
}
|
||||
|
||||
/** The subscription a frame belongs to, when it names one. */
|
||||
private fun subIdOf(msg: Message): String? =
|
||||
when (msg) {
|
||||
is EventMessage -> msg.subId
|
||||
is EoseMessage -> msg.subId
|
||||
is ClosedMessage -> msg.subId
|
||||
is CountMessage -> msg.queryId
|
||||
else -> null
|
||||
}
|
||||
|
||||
override fun onCannotConnect(
|
||||
relay: IRelayClient,
|
||||
errorMessage: String,
|
||||
) {
|
||||
accountant.add(UsageKeys.relayConnectFails(isMobile(), isForeground()), 1)
|
||||
// A dial that ends here never reaches onConnected, so nothing else would
|
||||
// ever consume its start stamp.
|
||||
dialStartedAt.remove(relay.url)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RelayUsage"
|
||||
|
||||
/** NOTICE text is server-controlled; cap what reaches the log. */
|
||||
private const val MAX_NOTICE_LOG = 200
|
||||
|
||||
/** Ceiling on distinct wordings held in memory; relay prose is unbounded. */
|
||||
private const val MAX_DISTINCT_NOTICES = 200
|
||||
|
||||
/** Ceiling on remembered subscription-id purposes. See [subPurpose]. */
|
||||
private const val MAX_TRACKED_SUBS = 4_000
|
||||
|
||||
/** Recent-event-id window. See [recentEventIds]. */
|
||||
private const val MAX_TRACKED_EVENTS = 50_000
|
||||
}
|
||||
}
|
||||
|
||||
+12
-2
@@ -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)
|
||||
|
||||
+53
-1
@@ -75,16 +75,49 @@ class ResourceUsageReportAssembler {
|
||||
|
||||
sb.append("\nTechnical details (per epoch-day):\n")
|
||||
sb.append("```\n")
|
||||
days.toSortedMap().forEach { (day, counters) ->
|
||||
val sorted = days.toSortedMap()
|
||||
val included = newestDaysWithin(sorted, MAX_DUMP_CHARS)
|
||||
sorted.forEach { (day, counters) ->
|
||||
if (day !in included) return@forEach
|
||||
sb.append("day $day (today=$today)\n")
|
||||
counters.toSortedMap().forEach { (key, value) ->
|
||||
sb.append(" $key = $value\n")
|
||||
}
|
||||
}
|
||||
val omitted = sorted.size - included.size
|
||||
if (omitted > 0) {
|
||||
sb.append("($omitted earlier day(s) omitted to keep this report sendable; ")
|
||||
sb.append("the summary tables above still cover them)\n")
|
||||
}
|
||||
sb.append("```\n")
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The most recent days whose dumps fit in [budget], newest first, always
|
||||
* including at least the newest even if it alone exceeds it.
|
||||
*
|
||||
* Bounded by size rather than by a day count because the per-day size is not a
|
||||
* constant: it tracks how many distinct counters the build emits, and that has
|
||||
* grown by more than an order of magnitude. A fixed day count would have to be
|
||||
* re-tuned every time a counter family is added, and would be wrong in the
|
||||
* meantime.
|
||||
*/
|
||||
private fun newestDaysWithin(
|
||||
days: Map<Long, Map<String, Long>>,
|
||||
budget: Int,
|
||||
): Set<Long> {
|
||||
val included = mutableSetOf<Long>()
|
||||
var left = budget
|
||||
for (day in days.keys.sortedDescending()) {
|
||||
val size = days.getValue(day).entries.sumOf { it.key.length + DUMP_LINE_OVERHEAD }
|
||||
if (included.isNotEmpty() && size > left) break
|
||||
included.add(day)
|
||||
left -= size
|
||||
}
|
||||
return included
|
||||
}
|
||||
|
||||
private fun summaryTable(s: UsageSummary): String =
|
||||
buildString {
|
||||
append("| Metric | Value |\n")
|
||||
@@ -132,6 +165,25 @@ class ResourceUsageReportAssembler {
|
||||
/** Markdown table header/body separator row. */
|
||||
private const val TABLE_SEPARATOR = "| --- | --- |\n"
|
||||
|
||||
/**
|
||||
* Character budget for the raw per-day dump.
|
||||
*
|
||||
* This report exists to be sent to the developers as a NIP-17 DM, and relays
|
||||
* commonly cap events between 64 and 256 KB — so an unbounded dump does not
|
||||
* merely inconvenience, it makes the report unsendable by exactly the users
|
||||
* whose ledgers are most worth seeing. It also travels through a ~1 MB Binder
|
||||
* transaction when shared.
|
||||
*
|
||||
* The ledger keeps 30 days and a busy day now emits ~1,200 counters, which is
|
||||
* ~52 KB of dump per day — so "every retained day" would be ~1.5 MB. This
|
||||
* keeps the newest days and says how many it dropped; the summary tables
|
||||
* above are unaffected and still cover the whole window.
|
||||
*/
|
||||
private const val MAX_DUMP_CHARS = 64 * 1024
|
||||
|
||||
/** ` ` + ` = ` + the value, per dumped line. */
|
||||
private const val DUMP_LINE_OVERHEAD = 24
|
||||
|
||||
fun formatBytes(bytes: Long): String =
|
||||
when {
|
||||
bytes >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.2f GB", bytes / (1024.0 * 1024.0 * 1024.0))
|
||||
|
||||
+20
-2
@@ -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,
|
||||
|
||||
+629
-16
@@ -20,6 +20,18 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.resourceusage
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.basic.BasicRelayClient
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.AUTH_REQUIRED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.BLOCKED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.ERROR
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.INVALID
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RATE_LIMITED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.RESTRICTED
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MachineReadablePrefix.UNSUPPORTED
|
||||
import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayObserver
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Counter-key grammar for the resource-usage ledger. Keys are flat strings so
|
||||
* the on-disk store is schema-free — adding a counter never needs a migration.
|
||||
@@ -29,8 +41,28 @@ package com.vitorpamplona.amethyst.service.resourceusage
|
||||
* - visibility: `fg` (an activity is started) vs `bg`
|
||||
* - direction: `rx` (downloaded) vs `tx` (uploaded)
|
||||
*
|
||||
* Counters are sizes, durations, and counts only — never URLs, relay names, or
|
||||
* content. See plans/2026-07-12-resource-usage-ledger.md.
|
||||
* Counters are sizes, durations, and counts only — never content, and never a
|
||||
* full URL, and never a relay name.
|
||||
* See plans/2026-07-12-resource-usage-ledger.md.
|
||||
*
|
||||
* ## Reserved segments — read before adding a counter
|
||||
*
|
||||
* [sumMatching] matches by dot-segment *membership*, not by prefix, and every
|
||||
* headline figure in [UsageSummary] is built from it. A new key that happens to
|
||||
* contain one of these segments silently joins that sum:
|
||||
*
|
||||
* rx tx msg connms connects connfails reqs bursts activems
|
||||
* worker runs + every value in [HTTP_ROLES]
|
||||
*
|
||||
* Concretely: a key named `relay.rx.event.mobile.bg` would be counted by
|
||||
* `traffic(MOBILE, BG)` *in addition to* `relay.msg.mobile.bg.rx`, doubling the
|
||||
* reported data usage and halving the effective threshold of the
|
||||
* background-mobile-data alert. That is why the relay verb split below uses
|
||||
* `up`/`down` rather than `tx`/`rx`.
|
||||
*
|
||||
* `ResourceUsageLedgerTest.newKeysDoNotDisturbSummary` is the regression guard:
|
||||
* it asserts [UsageSummary.from] is value-identical with and without every key
|
||||
* this object can produce.
|
||||
*/
|
||||
object UsageKeys {
|
||||
const val MOBILE = "mobile"
|
||||
@@ -54,6 +86,42 @@ object UsageKeys {
|
||||
|
||||
val HTTP_ROLES = listOf(ROLE_IMAGE, ROLE_VIDEO, ROLE_UPLOADS, ROLE_MONEY, ROLE_NIP05, ROLE_PREVIEW, ROLE_PUSH, ROLE_OTHER)
|
||||
|
||||
/**
|
||||
* `mobile.bg` — the network x visibility pair every counter is split by.
|
||||
*
|
||||
* Table-backed rather than interpolated: this is evaluated on every relay frame
|
||||
* and every HTTP response, and there are only four possible answers. Declared
|
||||
* first because the key tables below are built from it at class-init.
|
||||
*/
|
||||
fun dim(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = DIMS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val DIMS = arrayOf("$WIFI.$BG", "$WIFI.$FG", "$MOBILE.$BG", "$MOBILE.$FG")
|
||||
|
||||
private fun dimIndex(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): Int = (if (mobile) 2 else 0) or (if (foreground) 1 else 0)
|
||||
|
||||
/**
|
||||
* The four `<prefix>.<dim>[.<suffix>]` keys, indexed by [dimIndex].
|
||||
*
|
||||
* Used for every `relay.*` key, because all of them are built from a relay
|
||||
* callback — per frame for [relayMsg]/[relayVerb], per dial/connect/disconnect
|
||||
* for the rest. The `net.*` builders below still interpolate: their key space is
|
||||
* role x dim x metric and they are called once per HTTP response, so the table
|
||||
* would be larger and buy less. If you add a `relay.*` counter, table it.
|
||||
*/
|
||||
private fun dimKeys(
|
||||
prefix: String,
|
||||
suffix: String? = null,
|
||||
): Array<String> {
|
||||
val tail = if (suffix == null) "" else ".$suffix"
|
||||
return Array(DIMS.size) { "$prefix.${DIMS[it]}$tail" }
|
||||
}
|
||||
|
||||
/** `net.image.mobile.bg.rx` — HTTP bytes for a subsystem. */
|
||||
fun net(
|
||||
role: String,
|
||||
@@ -87,25 +155,556 @@ object UsageKeys {
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
received: Boolean,
|
||||
): String = "relay.msg.${dim(mobile, foreground)}.${if (received) RX else TX}"
|
||||
): String = (if (received) RELAY_MSG_RX else RELAY_MSG_TX)[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_MSG_RX = dimKeys("relay.msg", RX)
|
||||
private val RELAY_MSG_TX = dimKeys("relay.msg", TX)
|
||||
|
||||
/** `relay.connms.mobile.bg` — Σ(open relay connections × elapsed ms). */
|
||||
fun relayConnMs(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connms.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNMS[dimIndex(mobile, foreground)]
|
||||
|
||||
/** `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a TCP+TLS handshake. */
|
||||
private val RELAY_CONNMS = dimKeys("relay.connms")
|
||||
|
||||
/**
|
||||
* `relay.connects.mobile.bg` — completed relay (re)connections: each one paid a
|
||||
* TCP+TLS handshake.
|
||||
*
|
||||
* Also the [relayLife] histogram's denominator — one session begins per
|
||||
* `onConnected` — which is what makes the histogram's deficit measurable rather
|
||||
* than assumed:
|
||||
*
|
||||
* connects − Σ life buckets − orphan = still open at report time + lost to process death
|
||||
*
|
||||
* Without that subtraction a leak, a still-open session and a session lost to a
|
||||
* background kill are indistinguishable.
|
||||
*/
|
||||
fun relayConnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connects.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNECTS[dimIndex(mobile, foreground)]
|
||||
|
||||
/** `relay.connfails.mobile.bg` — dials that failed before the websocket opened. */
|
||||
private val RELAY_CONNECTS = dimKeys("relay.connects")
|
||||
|
||||
/**
|
||||
* `relay.connfails.mobile.bg` — every `onCannotConnect`.
|
||||
*
|
||||
* NOT a failed-dial count, despite the name. `BasicRelayClient.onFailure`
|
||||
* raises `onCannotConnect` with no `isReady` test, so a connection that lived
|
||||
* for ten minutes and then dropped increments both this and [relayConnects]
|
||||
* from a single dial. Use [relayDials] for the actual number of dials.
|
||||
*/
|
||||
fun relayConnectFails(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "relay.connfails.${dim(mobile, foreground)}"
|
||||
): String = RELAY_CONNFAILS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_CONNFAILS = dimKeys("relay.connfails")
|
||||
|
||||
/**
|
||||
* `relay.dials.mobile.bg` — dial attempts, from `onConnecting`.
|
||||
*
|
||||
* Fires exactly once per dial, after the transport gate and the connect mutex
|
||||
* and before the socket is built, so this is the honest denominator that
|
||||
* [relayConnectFails] is not.
|
||||
*/
|
||||
fun relayDials(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_DIALS[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_DIALS = dimKeys("relay.dials")
|
||||
|
||||
/** `relay.disc.mobile.bg` — every `onDisconnected`, whatever the cause. */
|
||||
fun relayDisconnects(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_DISC[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_DISC = dimKeys("relay.disc")
|
||||
|
||||
/**
|
||||
* `relay.subs.sent.mobile.bg` — REQ commands sent.
|
||||
*
|
||||
* A count to sit beside the `relay.verb.up.req` byte total: PR #3832 raised the
|
||||
* number of live subscriptions per relay (background accounts now subscribe too)
|
||||
* and measured refusals against nos.lol's cap of 20. Divided by [relayConnects]
|
||||
* this is REQs per connection, which is what separates "we reconnect too often"
|
||||
* from "each reconnect asks for too much" — different fixes.
|
||||
*/
|
||||
fun relaySubsSent(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_SENT[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_SENT = dimKeys("relay.subs.sent")
|
||||
|
||||
/** `relay.subs.closed.mobile.bg` — CLOSE commands sent; sent minus closed is net subscription growth. */
|
||||
fun relaySubsClosed(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_CLOSED[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_CLOSED = dimKeys("relay.subs.closed")
|
||||
|
||||
/**
|
||||
* `relay.subs.replay.mobile.bg` — REQs sent within [REPLAY_WINDOW_MS] of that
|
||||
* relay's connect, i.e. the post-connect resubscribe burst.
|
||||
*
|
||||
* An estimate, not an exact split. `PoolRequests.syncState` replays every desired
|
||||
* filter from a coroutine launched at `onConnected`, but nothing on the listener
|
||||
* side marks a frame as belonging to it, so this is a time window. It is the
|
||||
* measurement behind the source report's inference that ~24 KB per connection
|
||||
* "is exactly the size of a full REQ subscription replay" — which was arithmetic
|
||||
* on a daily total, not an observation.
|
||||
*/
|
||||
fun relaySubsReplay(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_REPLAY[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_REPLAY = dimKeys("relay.subs.replay")
|
||||
|
||||
/** How long after a connect a REQ still counts as part of the resubscribe burst. */
|
||||
const val REPLAY_WINDOW_MS = 2_000L
|
||||
|
||||
/**
|
||||
* `relay.notice.toomanysubs` — NOTICE frames by reason.
|
||||
*
|
||||
* Exists because a refused subscription is otherwise invisible. Per PR #3832's
|
||||
* own known issue, `ERROR: too many concurrent REQs` arrives as a NOTICE, which
|
||||
* carries no subscription id and so never reaches `RelayReqRefusals.onRefused`
|
||||
* (wired to CLOSED only). The relay drops the REQ while the client still believes
|
||||
* it is live — it never EOSEs, its `since` never advances, and `syncState` then
|
||||
* re-requests its full backlog on every reconnect, forever. A non-trivial count
|
||||
* here means subscription pressure is a *cause* of the download volume rather
|
||||
* than a symptom of the reconnect count.
|
||||
*
|
||||
* The reason comes from a fixed allowlist, never from the relay's text. Relay
|
||||
* prose is server-controlled and this key is persisted for 30 days; `RelayObserver`
|
||||
* had to fix exactly this bug, where free-form CLOSED prose became its own tally
|
||||
* key and cardinality grew with the number of distinct sentences relays wrote.
|
||||
*/
|
||||
fun relayNotice(reason: String): String = RELAY_NOTICE[reason] ?: RELAY_NOTICE.getValue(NOTICE_UNCLASSIFIED)
|
||||
|
||||
const val NOTICE_TOO_MANY_SUBS = "toomanysubs"
|
||||
const val NOTICE_RATE_LIMITED = "ratelimited"
|
||||
const val NOTICE_AUTH_REQUIRED = "authrequired"
|
||||
const val NOTICE_RESTRICTED = "restricted"
|
||||
const val NOTICE_INVALID = "invalid"
|
||||
const val NOTICE_BLOCKED = "blocked"
|
||||
const val NOTICE_ERROR = "error"
|
||||
const val NOTICE_UNSUPPORTED = "unsupported"
|
||||
|
||||
/** The query itself was too expensive — too many kinds/steps/filters. Observed on nostr.land, relay.layer.systems. */
|
||||
const val NOTICE_QUERY_COST = "querycost"
|
||||
|
||||
/** The relay refuses REQs outright. Observed on sendit.nosflare.com. */
|
||||
const val NOTICE_REQ_REFUSED = "reqrefused"
|
||||
|
||||
/** Relay chatter that costs bytes but means nothing — keepalives, per-query PERF telemetry. */
|
||||
const val NOTICE_BENIGN = "benign"
|
||||
|
||||
/** Deliberately not `other`: that is an [HTTP_ROLES] value and a reserved segment. */
|
||||
const val NOTICE_UNCLASSIFIED = "unclassified"
|
||||
|
||||
val NOTICE_REASONS =
|
||||
listOf(
|
||||
NOTICE_TOO_MANY_SUBS,
|
||||
NOTICE_RATE_LIMITED,
|
||||
NOTICE_AUTH_REQUIRED,
|
||||
NOTICE_RESTRICTED,
|
||||
NOTICE_INVALID,
|
||||
NOTICE_BLOCKED,
|
||||
NOTICE_ERROR,
|
||||
NOTICE_UNSUPPORTED,
|
||||
NOTICE_QUERY_COST,
|
||||
NOTICE_REQ_REFUSED,
|
||||
NOTICE_BENIGN,
|
||||
NOTICE_UNCLASSIFIED,
|
||||
)
|
||||
|
||||
private val RELAY_NOTICE = NOTICE_REASONS.associateWith { "relay.notice.$it" }
|
||||
|
||||
/**
|
||||
* Classifies a NOTICE into one of [NOTICE_REASONS].
|
||||
*
|
||||
* Matches on content markers rather than the NIP-01 machine-readable prefix
|
||||
* alone, because the case this exists for does not have a useful one: strfry
|
||||
* sends `ERROR: too many concurrent REQs`, whose prefix is just `error`.
|
||||
* [RelayObserver.prefixOf] is consulted for the standard prefixes it does
|
||||
* handle correctly.
|
||||
*/
|
||||
fun noticeReason(message: String): String {
|
||||
val text = message.lowercase()
|
||||
// Several relays prefix a NOTICE with the subscription id it concerns
|
||||
// ("Kgo0HH: closed: too many steps"), which makes the *subscription id* the
|
||||
// machine-readable prefix and hides the real one. Try the remainder too.
|
||||
val prefix = RelayObserver.prefixOf(message)
|
||||
val inner = RelayObserver.prefixOf(message.substringAfter(':', ""))
|
||||
val prefixes = setOf(prefix, inner)
|
||||
return when {
|
||||
"too many" in text && ("req" in text || "subscription" in text || "concurrent" in text) -> NOTICE_TOO_MANY_SUBS
|
||||
// A cost refusal is still a refusal: the REQ is dropped, so it never
|
||||
// EOSEs and its `since` never advances.
|
||||
"too many" in text || "too costly" in text || "too expensive" in text -> NOTICE_QUERY_COST
|
||||
"does not accept" in text || "denied" in text || "not accepting" in text -> NOTICE_REQ_REFUSED
|
||||
"keepalive" in text || "perf:" in text -> NOTICE_BENIGN
|
||||
// Wire codes come from the enum rather than being hand-written a third
|
||||
// time (after the enum itself and the NOTICE_* key segments).
|
||||
RATE_LIMITED.code in prefixes || ("rate" in text && "limit" in text) -> NOTICE_RATE_LIMITED
|
||||
AUTH_REQUIRED.code in prefixes || ("auth" in text && "required" in text) -> NOTICE_AUTH_REQUIRED
|
||||
RESTRICTED.code in prefixes -> NOTICE_RESTRICTED
|
||||
INVALID.code in prefixes -> NOTICE_INVALID
|
||||
BLOCKED.code in prefixes -> NOTICE_BLOCKED
|
||||
UNSUPPORTED.code in prefixes -> NOTICE_UNSUPPORTED
|
||||
ERROR.code in prefixes -> NOTICE_ERROR
|
||||
else -> NOTICE_UNCLASSIFIED
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar that decides reconnect behaviour, and so also what counts as a short
|
||||
* session: below it a disconnect keeps the growing backoff,
|
||||
* at or above it the backoff resets to 1s. (`NostrClient.KEEP_ALIVE_INTERVAL_MS`
|
||||
* is the same 60s, but it is private, so this reads the one that is public.)
|
||||
*
|
||||
* Derived rather than copied: the plan retunes `STABLE_CONNECTION_IN_SECS` once
|
||||
* the histogram is read, and a hand-written 60_000 here would silently stop
|
||||
* meaning "session that kept the backoff growing" at that point.
|
||||
* `UsageKeyHelpersTest.lifeBucketsAreHalfOpen` asserts the resulting bucket
|
||||
* labels, so a retune surfaces as a test failure rather than as a histogram that
|
||||
* quietly answers the wrong question.
|
||||
*/
|
||||
const val SHORT_SESSION_MS = BasicRelayClient.STABLE_CONNECTION_IN_SECS * 1_000L
|
||||
|
||||
/**
|
||||
* A half-open millisecond histogram: ascending upper [bounds], the derived
|
||||
* bucket labels, and the `<prefix>.<bucket>.<dim>` key table they index.
|
||||
*
|
||||
* Shared by all three histograms below (`relay.life`, `relay.hs`, `relay.gap`),
|
||||
* which differ only in their bounds and in whether the label reads in seconds or
|
||||
* milliseconds. Deriving the names from the bounds is what keeps a bound change
|
||||
* from leaving a label lying about it.
|
||||
*/
|
||||
private class MsHistogram(
|
||||
prefix: String,
|
||||
private val bounds: LongArray,
|
||||
label: (Long) -> String,
|
||||
) {
|
||||
init {
|
||||
// [indexOf] linear-scans for the first bound greater than the elapsed
|
||||
// time, so the bounds must ascend. One of relay.life's is derived from
|
||||
// BasicRelayClient.STABLE_CONNECTION_IN_SECS, and raising that to five
|
||||
// minutes — exactly the retune commit 2 of the churn plan contemplates —
|
||||
// would push it past the two bounds after it. The buckets between would
|
||||
// become unreachable and the labels would start lying. Fail at class-init
|
||||
// with the reason rather than as a puzzling boundary-test failure.
|
||||
require(bounds.asList() == bounds.sorted()) {
|
||||
"$prefix bounds must ascend, got ${bounds.toList()}. " +
|
||||
"SHORT_SESSION_MS is ${SHORT_SESSION_MS}ms — reorder the bounds to match."
|
||||
}
|
||||
}
|
||||
|
||||
val names = Array(bounds.size + 1) { i -> if (i < bounds.size) "lt${label(bounds[i])}" else "gte${label(bounds.last())}" }
|
||||
|
||||
private val keys = Array(names.size) { dimKeys("$prefix.${names[it]}") }
|
||||
|
||||
fun indexOf(ms: Long): Int {
|
||||
for (i in bounds.indices) {
|
||||
if (ms < bounds[i]) return i
|
||||
}
|
||||
return bounds.size
|
||||
}
|
||||
|
||||
fun nameOf(ms: Long): String = names[indexOf(ms)]
|
||||
|
||||
fun key(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = keys[indexOf(ms)][dimIndex(mobile, foreground)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds for the [relayLife] histogram deliberately straddle [SHORT_SESSION_MS]:
|
||||
* a mean cannot tell a tight cluster sitting on that bar from a bimodal mix;
|
||||
* this can.
|
||||
*/
|
||||
private val LIFE = MsHistogram("relay.life", longArrayOf(5_000, 30_000, SHORT_SESSION_MS, 120_000, 300_000)) { "${it / 1000}s" }
|
||||
|
||||
fun lifeBucket(elapsedMs: Long): String = LIFE.nameOf(elapsedMs)
|
||||
|
||||
/**
|
||||
* `relay.life.lt60s.mobile.bg` — connections that closed after living this long.
|
||||
* [relayConnects] is the denominator; see its doc for the deficit equation.
|
||||
*/
|
||||
fun relayLife(
|
||||
elapsedMs: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = LIFE.key(elapsedMs, mobile, foreground)
|
||||
|
||||
/**
|
||||
* `relay.life.overwrite` — a connect arrived for a relay that already had an
|
||||
* unconsumed start stamp.
|
||||
*
|
||||
* Expected during a pool teardown: `NostrClient` runs `disconnect()` then
|
||||
* `connect()` synchronously, so a stale failure callback for the old socket
|
||||
* can land after the new socket is already open. The new stamp overwrites the
|
||||
* old, and the stale disconnect then consumes the new one — booking a
|
||||
* near-zero lifetime for a session that never ended. Bias runs toward `lt5s`,
|
||||
* so read this before reading the histogram's short buckets.
|
||||
*/
|
||||
const val RELAY_LIFE_OVERWRITE = "relay.life.overwrite"
|
||||
|
||||
/** `relay.life.orphan` — a disconnect with no matching start stamp. */
|
||||
const val RELAY_LIFE_ORPHAN = "relay.life.orphan"
|
||||
|
||||
/**
|
||||
* `relay.verb.up.req.mobile.bg` / `relay.verb.down.eose.mobile.bg` — the
|
||||
* [relayMsg] bytes, split by wire verb. Parameterised on direction for the same
|
||||
* reason [relayMsg] is: one memo strategy, not two.
|
||||
*
|
||||
* `verb` is the command's own `label()` (`REQ`, `EVENT`, ...) lowercased, so a
|
||||
* new `Command`/`Message` subtype maps itself and nothing can land in a
|
||||
* catch-all bucket unnoticed. Deliberately `up`/`down` rather than `tx`/`rx` —
|
||||
* see the reserved-segment note above.
|
||||
*
|
||||
* Keys are memoized because this is on the per-frame path: the verb x dim space
|
||||
* is a handful of entries, so steady state is a map lookup and an array index
|
||||
* with no string building at all.
|
||||
*/
|
||||
fun relayVerb(
|
||||
verb: String,
|
||||
received: Boolean,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String =
|
||||
(if (received) VERB_DOWN_KEYS else VERB_UP_KEYS)
|
||||
.getOrPut(verb) { dimKeys("relay.verb.${if (received) DOWN else UP}.${verb.lowercase()}") }[dimIndex(mobile, foreground)]
|
||||
|
||||
private const val UP = "up"
|
||||
private const val DOWN = "down"
|
||||
|
||||
private val VERB_UP_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
private val VERB_DOWN_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
|
||||
/**
|
||||
* `relay.purpose.home_feed.sent` / `.bytes` — REQ frames and REQ bytes by the
|
||||
* [SubPurpose] that asked for them.
|
||||
*
|
||||
* The counter that turns "REQ traffic is 64 % of upload" into an actionable
|
||||
* name. Purpose travels on the filter itself (PR #3832's `ExplainedFilter`,
|
||||
* which survives the `copy(since = …)` assemblers do after every EOSE), so this
|
||||
* is a read, not a new registry.
|
||||
*
|
||||
* Cardinality is the enum, so it is bounded and stable. [PURPOSE_UNEXPLAINED] is
|
||||
* its own bucket rather than folded into the enum's OTHER: a filter carrying no
|
||||
* purpose at all means an assembler #3832 did not reach, which is a different
|
||||
* fact from one that declared itself uncategorised — and if that bucket is large,
|
||||
* the attribution below cannot be trusted.
|
||||
*
|
||||
* Memoized for the same reason [relayVerb] is, and more urgently: [relayPurposeDown]
|
||||
* and [relayPurposeDownCount] both run on every inbound frame that names a
|
||||
* subscription, so interpolating would build two strings per frame on the hottest
|
||||
* path in the app. The purpose space is the enum plus the three fallbacks below.
|
||||
*/
|
||||
fun relayPurposeSent(purpose: String): String = purposeKeys(purpose)[P_SENT]
|
||||
|
||||
fun relayPurposeBytes(purpose: String): String = purposeKeys(purpose)[P_BYTES]
|
||||
|
||||
/**
|
||||
* `relay.purpose.moderation.down` — bytes received on subscriptions opened for
|
||||
* that purpose, resolved through the subscription id the frame carries.
|
||||
*
|
||||
* The upload counters answer "who is asking"; this answers "who is being
|
||||
* answered", which is the larger number: inbound EVENT payload is ~74 % of relay
|
||||
* traffic against ~26 % outbound. Without it, a fix to the REQ churn can only be
|
||||
* credited with the upload it removes, when the interesting question is how much
|
||||
* of the download it was causing — every re-subscription can make the relay
|
||||
* re-send everything that matches.
|
||||
*/
|
||||
fun relayPurposeDown(purpose: String): String = purposeKeys(purpose)[P_DOWN]
|
||||
|
||||
/**
|
||||
* `relay.purpose.home_feed.downn` — inbound frames, alongside the bytes.
|
||||
*
|
||||
* Bytes alone cannot separate "many small events delivered repeatedly" from "few
|
||||
* large ones", and those want opposite fixes. With a count, `down / downn` is the
|
||||
* average frame size per purpose, and the total frame count set against
|
||||
* `crypto.verify.count` — which the cache pays once per event it accepts — bounds
|
||||
* how much of the download is the same events arriving from different relays
|
||||
* under the outbox fan-out.
|
||||
*/
|
||||
fun relayPurposeDownCount(purpose: String): String = purposeKeys(purpose)[P_DOWNN]
|
||||
|
||||
/**
|
||||
* `relay.purpose.user_profile.dupbytes` — of that purpose's inbound bytes, how
|
||||
* many carried an event already delivered.
|
||||
*
|
||||
* [relayEventsDupBytes] measures duplication across the whole client, which says
|
||||
* how much is wasted but not where. The seventh reading needs exactly this split:
|
||||
* `user_profile` was 57 % of download at ~17 KB per event, and whether that is
|
||||
* mostly the same events arriving from many relays or mostly distinct large ones
|
||||
* points at completely different fixes — suppress redundant delivery, or stop
|
||||
* fetching the large thing per relay.
|
||||
*/
|
||||
fun relayPurposeDupBytes(purpose: String): String = purposeKeys(purpose)[P_DUPBYTES]
|
||||
|
||||
private const val P_SENT = 0
|
||||
private const val P_BYTES = 1
|
||||
private const val P_DOWN = 2
|
||||
private const val P_DOWNN = 3
|
||||
private const val P_DUPBYTES = 4
|
||||
|
||||
private val PURPOSE_SUFFIXES = arrayOf("sent", "bytes", "down", "downn", "dupbytes")
|
||||
|
||||
private val PURPOSE_KEYS = ConcurrentHashMap<String, Array<String>>()
|
||||
|
||||
/** The five `relay.purpose.<purpose>.*` keys, indexed by the `P_` constants above. */
|
||||
private fun purposeKeys(purpose: String): Array<String> = PURPOSE_KEYS.getOrPut(purpose) { Array(PURPOSE_SUFFIXES.size) { "relay.purpose.$purpose.${PURPOSE_SUFFIXES[it]}" } }
|
||||
|
||||
/** A frame whose subscription id we never saw opened — counters wiped mid-session, or a sub from before this connection. */
|
||||
const val PURPOSE_UNATTRIBUTED = "unattributed"
|
||||
|
||||
/** A REQ whose filters carry no [ExplainedFilter] purpose. */
|
||||
const val PURPOSE_UNEXPLAINED = "unexplained"
|
||||
|
||||
/** The enum's own OTHER, renamed: bare `other` is an [HTTP_ROLES] value and a reserved segment. */
|
||||
const val PURPOSE_OTHER = "otherpurpose"
|
||||
|
||||
/**
|
||||
* The key segment for a [SubPurpose]. The one place the enum is turned into a
|
||||
* key, so the reserved-segment rename cannot drift between the producer and the
|
||||
* test that guards it — which is exactly how it drifted the first time.
|
||||
*
|
||||
* Tabled by ordinal: this runs per REQ, and `name.lowercase()` would allocate a
|
||||
* fresh String each time for one of a dozen fixed answers.
|
||||
*/
|
||||
fun purposeKeyPart(purpose: SubPurpose): String = PURPOSE_PARTS[purpose.ordinal]
|
||||
|
||||
private val PURPOSE_PARTS =
|
||||
Array(SubPurpose.entries.size) {
|
||||
val purpose = SubPurpose.entries[it]
|
||||
if (purpose == SubPurpose.OTHER) PURPOSE_OTHER else purpose.name.lowercase()
|
||||
}
|
||||
|
||||
/**
|
||||
* `relay.subs.resent.mobile.bg` — a REQ for a subscription id this relay already
|
||||
* has open on the current connection.
|
||||
*
|
||||
* The distinction the churn question turns on. A REQ that opens a new
|
||||
* subscription is work; a REQ that replaces one already in flight is the client
|
||||
* changing its mind, and at ~1 KB each that is pure cost. Measured against
|
||||
* [relaySubsSent] it says what fraction of the upload is re-subscription rather
|
||||
* than subscription.
|
||||
*/
|
||||
fun relaySubsResent(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_SUBS_RESENT[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_SUBS_RESENT = dimKeys("relay.subs.resent")
|
||||
|
||||
/** Half-open bounds, in ms, shared by the two connect-timing histograms below. */
|
||||
private val CONNECT_BOUNDS_MS = longArrayOf(100, 500, 2_000, 10_000, 30_000)
|
||||
|
||||
/**
|
||||
* `relay.hs.lt500ms.wifi.fg` — the websocket upgrade round-trip, as the
|
||||
* transport measured it.
|
||||
*
|
||||
* This is `onConnected`'s `pingMillis`, which `BasicOkHttpWebSocket` computes as
|
||||
* `receivedResponseAtMillis - sentRequestAtMillis` and which this listener
|
||||
* previously discarded. PR #3843 is the cautionary tale: `RelayObserver` derived
|
||||
* the same quantity from `onConnecting -> onConnected` instead, and on a large
|
||||
* fan-out published a 33.5 s median that was the client's own backlog rather than
|
||||
* relay latency. Those timestamps bracket the request itself, so everything
|
||||
* before it — queueing, DNS, TCP, TLS — is excluded. Zero or negative means the
|
||||
* transport could not time it, and nothing is recorded rather than a fabricated 0.
|
||||
*/
|
||||
fun relayHandshake(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = HANDSHAKE.key(ms, mobile, foreground)
|
||||
|
||||
private val HANDSHAKE = MsHistogram("relay.hs", CONNECT_BOUNDS_MS) { "${it}ms" }
|
||||
|
||||
/**
|
||||
* `relay.gap.lt2000ms.wifi.fg` — everything between deciding to dial and the
|
||||
* upgrade request going out: dispatcher queueing, DNS, TCP, TLS.
|
||||
*
|
||||
* `(onConnected wall clock - onConnecting wall clock) - handshake`. This is the
|
||||
* share of connect latency the app is responsible for rather than the relay, and
|
||||
* it is what decides whether a high never-became-ready rate is relays being
|
||||
* unreachable or ~500 simultaneous dials saturating name resolution and sockets.
|
||||
* The dispatcher's own cap is not the constraint on a phone
|
||||
* (`maxRequests = 1024`, `maxRequestsPerHost = 10`), so a large value here points
|
||||
* at resolution and socket setup, not at a queue.
|
||||
*/
|
||||
fun relayDialGap(
|
||||
ms: Long,
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = DIAL_GAP.key(ms, mobile, foreground)
|
||||
|
||||
private val DIAL_GAP = MsHistogram("relay.gap", CONNECT_BOUNDS_MS) { "${it}ms" }
|
||||
|
||||
/**
|
||||
* `relay.events.seen.wifi.fg` — inbound EVENT frames, and of those, how many
|
||||
* carried an event id already delivered recently.
|
||||
*
|
||||
* The outbox model asks many relays for the same authors, so one event is
|
||||
* delivered once per relay that carries it. Relay download is ~65 % of all data
|
||||
* on the release build, so the duplication factor decides whether the largest
|
||||
* number in the ledger is content or repetition — a question no other counter
|
||||
* here can answer, and one [VERIFY_COUNT] only proxies (it counts what the cache
|
||||
* accepted, not what arrived, and only while dedup-before-verify holds).
|
||||
*
|
||||
* [relayEventsDupBytes] is the number that matters: bytes that arrived and were
|
||||
* already held.
|
||||
*/
|
||||
fun relayEventsSeen(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_SEEN[dimIndex(mobile, foreground)]
|
||||
|
||||
fun relayEventsDup(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_DUP[dimIndex(mobile, foreground)]
|
||||
|
||||
fun relayEventsDupBytes(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = RELAY_EV_DUPB[dimIndex(mobile, foreground)]
|
||||
|
||||
private val RELAY_EV_SEEN = dimKeys("relay.events.seen")
|
||||
private val RELAY_EV_DUP = dimKeys("relay.events.dup")
|
||||
private val RELAY_EV_DUPB = dimKeys("relay.events.dupbytes")
|
||||
|
||||
/**
|
||||
* `relay.trigger.netid` — reconnect *decisions*, by cause, not reconnects
|
||||
* performed.
|
||||
*
|
||||
* Two reasons this is an upper bound, both of which matter when reading it:
|
||||
* `NostrClient.reconnect` emits into a debounced flow that `subscribe` /
|
||||
* `count` / `publish` / `onDisconnected` also feed very frequently, so a
|
||||
* teardown can be coalesced away before it runs; and the flow's initial value
|
||||
* fires one teardown per client construction with no trigger attributed.
|
||||
*/
|
||||
fun relayTrigger(cause: String): String = "relay.trigger.$cause"
|
||||
|
||||
const val TRIGGER_NETID = "netid"
|
||||
const val TRIGGER_TRANSPORT = "transport"
|
||||
const val TRIGGER_TOR_POLICY = "torpolicy"
|
||||
const val TRIGGER_CLASSIFICATION = "class"
|
||||
const val TRIGGER_COLD_START = "coldstart"
|
||||
const val TRIGGER_OFF = "off"
|
||||
const val TRIGGER_BUZZ = "buzz"
|
||||
|
||||
/** `worker.scheduledPost.runs` */
|
||||
fun workerRuns(worker: String): String = "worker.$worker.runs"
|
||||
@@ -187,18 +786,32 @@ object UsageKeys {
|
||||
const val BATTERY_DRAIN_FG = "battery.drain.fg"
|
||||
const val BATTERY_DRAIN_BG = "battery.drain.bg"
|
||||
|
||||
fun dim(
|
||||
mobile: Boolean,
|
||||
foreground: Boolean,
|
||||
): String = "${if (mobile) MOBILE else WIFI}.${if (foreground) FG else BG}"
|
||||
|
||||
/** Sums every counter whose key matches all the given dot-delimited parts. */
|
||||
/**
|
||||
* Sums every counter whose key matches all the given dot-delimited parts.
|
||||
*
|
||||
* Scans segments in place rather than `key.split('.')`: [UsageSummary.from] makes
|
||||
* ~54 of these passes over the whole day bucket, the usage screen builds 16
|
||||
* summaries per entry on the main thread, and the churn counters roughly tripled
|
||||
* the key count — none of which can ever match (that is what
|
||||
* `noNewKeyContainsAReservedSegment` guarantees), so every split was pure waste.
|
||||
*/
|
||||
fun Map<String, Long>.sumMatching(vararg parts: String): Long {
|
||||
var total = 0L
|
||||
for ((key, value) in this) {
|
||||
val segments = key.split('.')
|
||||
if (parts.all { it in segments }) total += value
|
||||
if (parts.all { key.hasSegment(it) }) total += value
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
/** True when `segment` is one of this key's whole dot-delimited segments. */
|
||||
private fun String.hasSegment(segment: String): Boolean {
|
||||
var from = 0
|
||||
while (from <= length) {
|
||||
var end = indexOf('.', from)
|
||||
if (end < 0) end = length
|
||||
if (end - from == segment.length && regionMatches(from, segment, 0, segment.length)) return true
|
||||
from = end + 1
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.util.countToHumanReadableBytes
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.ui.components.pdf.extractFilename
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
|
||||
/**
|
||||
* The renderer for a declared file that none of the media viewers can display — a webxdc app,
|
||||
* an archive, an installer, any MIME [com.vitorpamplona.amethyst.commons.richtext.RichTextParser.classifyMedia]
|
||||
* returns null for.
|
||||
*
|
||||
* It exists so those files have somewhere to land other than the video player: an unknown blob
|
||||
* used to fall through an image-or-else-video branch into ExoPlayer, which buffers forever on a
|
||||
* zip. Everything shown here comes off the event's own tags (NIP-94 `alt`, `m`, `size`), so the
|
||||
* card costs no network round-trip — unlike routing the URL through the OpenGraph previewer,
|
||||
* which would try to download the blob just to rediscover the type the event already declared.
|
||||
*/
|
||||
@Composable
|
||||
fun FileAttachmentCard(
|
||||
url: String,
|
||||
description: String?,
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val filename = remember(url) { extractFilename(url) }
|
||||
val subtitle = remember(mimeType, sizeInBytes) { fileSubtitle(mimeType, sizeInBytes) }
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
MaterialTheme.colorScheme.innerPostModifier
|
||||
.fillMaxWidth()
|
||||
.clickable { uriHandler.openUri(url) },
|
||||
) {
|
||||
FileAttachmentRow(
|
||||
symbol = MaterialSymbols.AttachFile,
|
||||
// The alt/content text names the file for a human ("Webxdc app: Quake");
|
||||
// the hashed URL basename is the fallback when the event omits it.
|
||||
title = description?.ifBlank { null } ?: filename,
|
||||
subtitle = subtitle,
|
||||
titleMaxLines = 2,
|
||||
)
|
||||
|
||||
Spacer(modifier = DoubleVertSpacer)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The icon + title + subtitle row shared by every card that stands in for a file it can't
|
||||
* render inline: this one and the PDF placeholder/skeleton in
|
||||
* [com.vitorpamplona.amethyst.ui.components.pdf.PdfPreviewCard].
|
||||
*/
|
||||
@Composable
|
||||
internal fun FileAttachmentRow(
|
||||
symbol: MaterialSymbol,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
titleMaxLines: Int = 1,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = symbol,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = titleMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (subtitle != null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** "APK · 16 MB", dropping either half when the event doesn't declare it. */
|
||||
private fun fileSubtitle(
|
||||
mimeType: String?,
|
||||
sizeInBytes: Long?,
|
||||
): String? =
|
||||
listOfNotNull(
|
||||
mimeType?.ifBlank { null }?.let(::prettyMime),
|
||||
sizeInBytes?.takeIf { it > 0 }?.let(::countToHumanReadableBytes),
|
||||
).joinToString(" · ").ifEmpty { null }
|
||||
@@ -25,6 +25,8 @@ import android.net.Uri
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.core.content.FileProvider
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.richtext.mimeTypeMap
|
||||
import com.vitorpamplona.amethyst.commons.richtext.normalizeMimeType
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -37,6 +39,7 @@ object ShareHelper {
|
||||
private const val DEFAULT_IMAGE_EXTENSION = "jpg"
|
||||
private const val DEFAULT_VIDEO_EXTENSION = "mp4"
|
||||
private const val SHARED_FILE_PREFIX = "shared_media"
|
||||
private const val GENERIC_BINARY_MIME_TYPE = "application/octet-stream"
|
||||
|
||||
data class SharableFile(
|
||||
val uri: Uri,
|
||||
@@ -65,6 +68,25 @@ object ShareHelper {
|
||||
private val MP4_BRAND_MP42 = "mp42".toByteArray()
|
||||
private val MOV_BRAND_QT = "qt ".toByteArray()
|
||||
|
||||
/**
|
||||
* Picks the MIME type to put on an `ACTION_SEND` intent.
|
||||
*
|
||||
* `Intent.type` has to be a real `type/subtype` — an `IntentFilter` matches the two halves
|
||||
* separately, so a slash-less value matches nothing and the chooser opens empty. The declared
|
||||
* type is author-supplied and may be unusable (see [normalizeMimeType]), so it is treated as a
|
||||
* hint; [fileExtension] is the safer signal because [getMediaExtension] sniffs it from the
|
||||
* file's magic numbers rather than trusting the event.
|
||||
*/
|
||||
internal fun resolveShareMimeType(
|
||||
declaredMimeType: String?,
|
||||
fileExtension: String,
|
||||
): String =
|
||||
normalizeMimeType(declaredMimeType)
|
||||
?: mimeTypeMap[fileExtension.lowercase()]
|
||||
// Unreachable today: getMediaExtension only ever returns keys of mimeTypeMap. Kept so
|
||||
// the return type stays a well-formed MIME if that ever stops holding.
|
||||
?: GENERIC_BINARY_MIME_TYPE
|
||||
|
||||
suspend fun getSharableUriFromUrl(
|
||||
context: Context,
|
||||
imageUrl: String,
|
||||
|
||||
+3
-3
@@ -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 =
|
||||
|
||||
+6
-39
@@ -26,38 +26,29 @@ import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.FilterQuality
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.graphics.createBitmap
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.ui.components.ClickableUrl
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentRow
|
||||
import com.vitorpamplona.amethyst.ui.components.ShareMediaAction
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.theme.DoubleVertSpacer
|
||||
import com.vitorpamplona.amethyst.ui.theme.MaxWidthWithHorzPadding
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size20Modifier
|
||||
import com.vitorpamplona.amethyst.ui.theme.innerPostModifier
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
@@ -207,35 +198,11 @@ private fun PdfSkeletonCard(filename: String) {
|
||||
private fun FilenameRow(
|
||||
filename: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
Row(
|
||||
modifier = MaxWidthWithHorzPadding.padding(vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
contentDescription = null,
|
||||
modifier = Size20Modifier,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = filename,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) = FileAttachmentRow(
|
||||
symbol = MaterialSymbols.PictureAsPdf,
|
||||
title = filename,
|
||||
subtitle = subtitle,
|
||||
)
|
||||
|
||||
private fun renderFirstPage(
|
||||
file: java.io.File,
|
||||
|
||||
@@ -50,6 +50,9 @@ import androidx.navigation.compose.composable
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.LocalEventFinder
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinder
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.LocalUserFinderAccount
|
||||
import com.vitorpamplona.amethyst.service.crashreports.DisplayCrashMessages
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose.DisplayNotifyMessages
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.DisplayResourceUsageAlert
|
||||
@@ -138,6 +141,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concor
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordCreateScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordEditScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordHomeScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteLinksScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordInviteScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.concord.ConcordMembersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.ephemChat.EphemeralChatScreen
|
||||
@@ -264,6 +268,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BlockedUsersScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.BottomBarSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.CallSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.ComposeSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.DrawerSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HiddenWordsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.HomeTabsSettingsScreen
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.MessagesSettingsScreen
|
||||
@@ -341,6 +346,15 @@ fun AppNavigation(
|
||||
CompositionLocalProvider(
|
||||
LocalScreenLayout provides screenLayout,
|
||||
LocalTabReselectCoordinator provides tabReselectCoordinator,
|
||||
// Provide the shared finder CompositionLocals so any commons composable that
|
||||
// uses the no-arg observeUser*/EventFinderFilterAssemblerSubscription(note)
|
||||
// overloads works when rendered on Android (they error() if unprovided). Android's
|
||||
// own UI uses the AccountViewModel overloads and doesn't strictly need these, but
|
||||
// providing them removes the runtime trap for shared composables reaching the
|
||||
// logged-in tree. (The :napplet process never renders these composables.)
|
||||
LocalUserFinder provides accountViewModel.dataSources().userFinder,
|
||||
LocalUserFinderAccount provides accountViewModel.account,
|
||||
LocalEventFinder provides accountViewModel.dataSources().eventFinder,
|
||||
) {
|
||||
AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) {
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
@@ -579,6 +593,7 @@ fun BuildNavigation(
|
||||
composableFromEnd<Route.MessagesSettings> { MessagesSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.AudioVisualizerSettings> { AudioVisualizerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.BottomBarSettings> { BottomBarSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.DrawerSettings> { DrawerSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.HomeTabsSettings> { HomeTabsSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.ProfileUiSettings> { ProfileUiSettingsScreen(accountViewModel, nav) }
|
||||
composableFromEnd<Route.VideoPlayerSettings> { VideoPlayerSettingsScreen(accountViewModel, nav) }
|
||||
@@ -747,6 +762,14 @@ fun BuildNavigation(
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.ConcordInviteLinks> {
|
||||
ConcordInviteLinksScreen(
|
||||
communityId = it.communityId,
|
||||
accountViewModel = accountViewModel,
|
||||
nav = nav,
|
||||
)
|
||||
}
|
||||
|
||||
composableFromEndArgs<Route.ConcordEdit> {
|
||||
ConcordEditScreen(
|
||||
communityId = it.communityId,
|
||||
|
||||
-28
@@ -20,13 +20,11 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
|
||||
@@ -58,29 +56,3 @@ fun keyboardAsState(): State<KeyboardState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [BackHandler] that steps aside while the soft keyboard is on screen.
|
||||
*
|
||||
* Chat composers (and draft-saving editors) intercept back to flush a draft and pop the screen.
|
||||
* When that pop happens while the keyboard is still up, it races the predictive-back window
|
||||
* animation against the IME's close animation. On release builds — fast enough that the window
|
||||
* animation wins — the IME [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat]
|
||||
* is cancelled before its terminal (zero) frame reaches Compose, so the shared `WindowInsets.ime`
|
||||
* holder stays "animating" and every `Modifier.imePadding()` in the app freezes at the keyboard
|
||||
* height until a later inset pass rebalances it (the "stuck IME padding" that survives leaving the
|
||||
* screen).
|
||||
*
|
||||
* Gating on [keyboardAsState] fixes it: while the keyboard is visible we do NOT consume back, so the
|
||||
* system dismisses the keyboard first with its own animation (which completes cleanly). The next
|
||||
* back — keyboard already down — runs [onBack] as before. The top bar's back arrow stays an
|
||||
* always-available exit, so this can never trap the user even if the inset reading were itself stale.
|
||||
*/
|
||||
@Composable
|
||||
fun KeyboardAwareBackHandler(
|
||||
enabled: Boolean = true,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val keyboardState by keyboardAsState()
|
||||
BackHandler(enabled = enabled && keyboardState == KeyboardState.Closed, onBack = onBack)
|
||||
}
|
||||
|
||||
+22
-66
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.bottombars
|
||||
|
||||
import android.os.Build
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
@@ -29,8 +28,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Stable identifiers for every drawer destination that the user can pin to the bottom bar.
|
||||
* Order in this enum has no semantic meaning — the user picks a subset and an order at runtime.
|
||||
* Stable identifiers for every destination the navigation surfaces can show — the bottom bar pins a
|
||||
* subset in a user-chosen order, the drawer lists them under fixed headings (see DrawerSections).
|
||||
* Order in this enum has no semantic meaning.
|
||||
*/
|
||||
@Serializable
|
||||
enum class NavBarItem {
|
||||
@@ -84,6 +84,18 @@ enum class NavBarItem {
|
||||
FAVORITE_ALGO_FEEDS,
|
||||
}
|
||||
|
||||
private val NavBarItemsByName = NavBarItem.entries.associateBy { it.name }
|
||||
|
||||
/**
|
||||
* Parses persisted [NavBarItem] names, silently dropping any this build doesn't know — a settings
|
||||
* blob synced from a newer client can name a destination that doesn't exist here yet, and that must
|
||||
* degrade to "ignore this one row" rather than failing the decode of the whole blob.
|
||||
*/
|
||||
fun navBarItemsFromNames(names: Collection<String>): Set<NavBarItem> = names.mapNotNullTo(mutableSetOf()) { NavBarItemsByName[it] }
|
||||
|
||||
/** The inverse of [navBarItemsFromNames]; sorted so the serialized form is deterministic. */
|
||||
fun Set<NavBarItem>.toNames(): List<String> = map { it.name }.sorted()
|
||||
|
||||
data class NavBarItemDef(
|
||||
val id: NavBarItem,
|
||||
val labelRes: Int,
|
||||
@@ -443,34 +455,6 @@ val DefaultBottomBarItems: List<NavBarItem> =
|
||||
/** The default bottom bar as unified entries (all built-in; favorites are added by the user). */
|
||||
val DefaultBottomBarEntries: List<BottomBarEntry> = DefaultBottomBarItems.map { BottomBarEntry.BuiltIn(it) }
|
||||
|
||||
// Ordered membership lists for each drawer section. The drawer renders these by looking up
|
||||
// each id in NavBarCatalog, so adding a new screen only requires editing the catalog + the
|
||||
// matching section list below — not two separate files.
|
||||
val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
/**
|
||||
* A titled, collapsible group of selectable destinations in the bottom-bar settings picker. The
|
||||
* catalog's [linkedMapOf] insertion order is hand-maintained and reads as scattered in the flat
|
||||
@@ -479,6 +463,7 @@ val DrawerYouItems: List<NavBarItem> =
|
||||
*/
|
||||
data class NavBarCategory(
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
)
|
||||
|
||||
@@ -491,6 +476,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
listOf(
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_main,
|
||||
MaterialSymbols.Home,
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
@@ -501,6 +487,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_chats,
|
||||
MaterialSymbols.Group,
|
||||
listOf(
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
@@ -510,6 +497,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_you,
|
||||
MaterialSymbols.AccountCircle,
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
@@ -527,6 +515,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_feeds,
|
||||
MaterialSymbols.Subscriptions,
|
||||
listOf(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.LONGS,
|
||||
@@ -553,6 +542,7 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_apps,
|
||||
MaterialSymbols.Apps,
|
||||
listOf(
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.FAVORITE_APPS,
|
||||
@@ -563,43 +553,9 @@ val BottomBarCategories: List<NavBarCategory> =
|
||||
),
|
||||
NavBarCategory(
|
||||
R.string.bottom_bar_category_other,
|
||||
MaterialSymbols.Settings,
|
||||
listOf(
|
||||
NavBarItem.SETTINGS,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
+59
-44
@@ -63,6 +63,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -105,9 +106,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse
|
||||
import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.layouts.PermanentDrawerWidth
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerFeedsItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerNavigateItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DrawerYouItems
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItemDef
|
||||
@@ -584,42 +582,17 @@ fun ListContent(
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
// Per-account, synced through the NIP-78 app-specific data event, and edited on the
|
||||
// Side Menu settings screen. Empty (the default) means the full stock drawer.
|
||||
val hidden by accountViewModel.hiddenDrawerItemsFlow().collectAsStateWithLifecycle()
|
||||
|
||||
Column(modifier) {
|
||||
CatalogSection(R.string.drawer_section_you, DrawerYouItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_navigate, DrawerNavigateItems, accountViewModel, nav)
|
||||
CatalogSection(R.string.drawer_section_feeds, DrawerFeedsItems, accountViewModel, nav)
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_create) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
CollapsibleSection(title = R.string.drawer_section_system) {
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
|
||||
NavBarCatalog[NavBarItem.SETTINGS]?.let {
|
||||
CatalogNavigationRow(it, MaterialTheme.colorScheme.onBackground, accountViewModel, nav)
|
||||
DrawerSections.forEach { section ->
|
||||
// Keyed by section: hiding the last row of a section removes it from the drawer
|
||||
// entirely, and without a key the sections below would slide up into its slots and
|
||||
// inherit its CollapsibleSection expanded/collapsed state.
|
||||
key(section.id) {
|
||||
CatalogSection(section, hidden, accountViewModel, nav)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,22 +607,64 @@ fun ListContent(
|
||||
}
|
||||
}
|
||||
|
||||
/** The Create section's rows — composer entry points, none of which is a catalog destination. */
|
||||
@Composable
|
||||
private fun CreateRows(nav: INav) {
|
||||
NavigationRow(
|
||||
title = R.string.share_hls_video,
|
||||
icon = MaterialSymbols.SettingsInputAntenna,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.NewHlsVideo,
|
||||
)
|
||||
|
||||
if (isDebug) {
|
||||
NavigationRow(
|
||||
title = R.string.route_chess,
|
||||
icon = MaterialSymbols.ChessKnight,
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
nav = nav,
|
||||
route = Route.Chess,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a drawer section by iterating [ids] and looking each one up in [NavBarCatalog].
|
||||
* Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
* Renders one drawer section: its fixed rows, if it has any, then the catalog rows the user hasn't
|
||||
* switched off. Profile gets the primary-colored tint; every other item uses onBackground.
|
||||
*
|
||||
* A section with nothing left to show renders nothing at all — an empty, permanently collapsed
|
||||
* heading is just noise. Two sections always have something: Create is entirely fixed rows, and
|
||||
* System carries the relay-status row (not a catalog destination — it shows a live counter).
|
||||
*/
|
||||
@Composable
|
||||
fun CatalogSection(
|
||||
titleRes: Int,
|
||||
ids: List<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
accountViewModel: AccountViewModel,
|
||||
nav: INav,
|
||||
) {
|
||||
val primary = MaterialTheme.colorScheme.primary
|
||||
val onBackground = MaterialTheme.colorScheme.onBackground
|
||||
|
||||
CollapsibleSection(title = titleRes) {
|
||||
ids.forEach { id ->
|
||||
val visible = remember(section, hidden) { DrawerItemVisibility.visibleItems(section, hidden) }
|
||||
if (visible.isEmpty() && !section.hasFixedRows) return
|
||||
|
||||
CollapsibleSection(title = section.titleRes) {
|
||||
when (section.id) {
|
||||
DrawerSectionId.CREATE -> CreateRows(nav)
|
||||
DrawerSectionId.SYSTEM ->
|
||||
IconRowRelays(
|
||||
accountViewModel = accountViewModel,
|
||||
onClick = {
|
||||
nav.closeDrawer()
|
||||
nav.nav(Route.EditRelays)
|
||||
},
|
||||
)
|
||||
else -> {}
|
||||
}
|
||||
|
||||
visible.forEach { id ->
|
||||
NavBarCatalog[id]?.let { def ->
|
||||
val tint = if (def.id == NavBarItem.PROFILE) primary else onBackground
|
||||
if (def.id == NavBarItem.SCHEDULED_POSTS) {
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.drawer
|
||||
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* Which drawer rows the user cannot hide.
|
||||
*
|
||||
* Settings is the only one, and it is mandatory for a specific reason: it is the route back to the
|
||||
* screen that hides rows in the first place. Hiding it would let a user lock themselves out of their
|
||||
* own configuration. Everything else the drawer always shows — the profile header, the relay-status
|
||||
* row, the account switcher and the version/QR footer — is fixed chrome rather than a catalog row,
|
||||
* so it is present by construction and never appears in the hidden set.
|
||||
*/
|
||||
val MandatoryDrawerItems: Set<NavBarItem> = setOf(NavBarItem.SETTINGS)
|
||||
|
||||
/**
|
||||
* Pure show/hide rules for the drawer's catalog rows, kept free of Compose and Android so they are
|
||||
* exercised directly by unit tests (DrawerItemVisibilityTest) rather than only through the UI.
|
||||
*
|
||||
* The per-account preference stores the **hidden** items rather than the visible ones. That choice is
|
||||
* what makes a newly added destination appear for everyone automatically: a row nobody has ever
|
||||
* hidden simply isn't in the set, so it renders. Storing the visible list instead would freeze each
|
||||
* account's drawer at the moment they first touched the setting, and every later release would have
|
||||
* to migrate saved lists to introduce a screen.
|
||||
*/
|
||||
object DrawerItemVisibility {
|
||||
fun isVisible(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Boolean = item in MandatoryDrawerItems || item !in hidden
|
||||
|
||||
/** Hides [item] if shown, shows it if hidden. Mandatory items never change (see [MandatoryDrawerItems]). */
|
||||
fun toggle(
|
||||
hidden: Set<NavBarItem>,
|
||||
item: NavBarItem,
|
||||
): Set<NavBarItem> =
|
||||
when {
|
||||
item in MandatoryDrawerItems -> hidden
|
||||
item in hidden -> hidden - item
|
||||
else -> hidden + item
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops mandatory rows from the set. The persistence layer is the single place this is enforced —
|
||||
* it runs on decode, on an external sync, and on every write — so a value synced from another
|
||||
* client (or from a build where the row wasn't mandatory yet) can't strand Settings as hidden.
|
||||
*
|
||||
* Ids that no section renders are deliberately *kept*: on a device where a row is gated off (see
|
||||
* DrawerFeedsItems' API-30 gate on Favorite Apps) it matches nothing and costs nothing, and
|
||||
* preserving it means editing the drawer on that device doesn't silently clear the choice the
|
||||
* user made on another one.
|
||||
*/
|
||||
fun sanitize(hidden: Set<NavBarItem>): Set<NavBarItem> = hidden - MandatoryDrawerItems
|
||||
|
||||
/** The rows of [section] to render, in the section's fixed order. */
|
||||
fun visibleItems(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): List<NavBarItem> = section.items.filter { isVisible(hidden, it) }
|
||||
|
||||
/** How many of [section]'s rows are currently hidden — shown on the collapsed section header. */
|
||||
fun hiddenCount(
|
||||
section: DrawerSection,
|
||||
hidden: Set<NavBarItem>,
|
||||
): Int = section.items.count { !isVisible(hidden, it) }
|
||||
|
||||
/** Whether [section] has any row the user is allowed to switch off — gates its bulk actions. */
|
||||
fun hasHideableRows(section: DrawerSection): Boolean = section.items.any { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Hides every row of [section] that can be hidden, leaving the mandatory ones. */
|
||||
fun hideAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden + section.items.filter { it !in MandatoryDrawerItems }
|
||||
|
||||
/** Shows every row of [section] again. */
|
||||
fun showAll(
|
||||
hidden: Set<NavBarItem>,
|
||||
section: DrawerSection,
|
||||
): Set<NavBarItem> = hidden - section.items.toSet()
|
||||
|
||||
/** Total hidden rows across every section — the count the settings screen shows at the top. */
|
||||
fun totalHidden(hidden: Set<NavBarItem>): Int = DrawerSections.sumOf { hiddenCount(it, hidden) }
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.drawer
|
||||
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarCatalog
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
|
||||
/**
|
||||
* The drawer's layout: which destinations it lists, under which heading, in which order.
|
||||
*
|
||||
* One list drives two screens — [ListContent] renders the visible rows of each section, and the Side
|
||||
* Menu settings screen renders the same sections as its show/hide catalog. Adding a destination to a
|
||||
* section's list therefore surfaces it in the drawer *and* in its configuration screen without
|
||||
* touching either, and DrawerSectionsTest fails the build if a newly added [NavBarCatalog] id isn't
|
||||
* filed into exactly one section.
|
||||
*
|
||||
* Section order and within-section order are fixed and not user-editable: the drawer is a menu, and a
|
||||
* menu whose headings move around is harder to learn, not easier. The only per-account choice is
|
||||
* which rows are visible — see [DrawerItemVisibility].
|
||||
*/
|
||||
@Immutable
|
||||
data class DrawerSection(
|
||||
val id: DrawerSectionId,
|
||||
val titleRes: Int,
|
||||
val icon: MaterialSymbol,
|
||||
val items: List<NavBarItem>,
|
||||
/**
|
||||
* True for a section that renders rows of its own on top of its catalog items (see [CatalogSection]).
|
||||
* Such a section stays in the drawer even with every catalog row switched off, and — since a fixed
|
||||
* row is not a catalog destination — it never appears in the Side Menu settings screen's counts.
|
||||
*/
|
||||
val hasFixedRows: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Identifies a section for the handful of rendering rules that are specific to one. Matching on this
|
||||
* rather than on a section's object identity keeps those rules working if the list is ever mapped or
|
||||
* copied — a `DrawerSections.map { it.copy(...) }` would silently defeat an `===` check, with no
|
||||
* compile error and nothing to fail a test.
|
||||
*/
|
||||
enum class DrawerSectionId {
|
||||
YOU,
|
||||
NAVIGATE,
|
||||
FEEDS,
|
||||
|
||||
/** Composer entry points. Carries no catalog destinations, so nothing in it is configurable. */
|
||||
CREATE,
|
||||
|
||||
/** Also renders the relay-status row, which isn't a catalog destination (it shows a live counter). */
|
||||
SYSTEM,
|
||||
}
|
||||
|
||||
private val DrawerNavigateItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.HOME,
|
||||
NavBarItem.MESSAGES,
|
||||
NavBarItem.VIDEO,
|
||||
NavBarItem.BROWSER,
|
||||
NavBarItem.DISCOVER,
|
||||
NavBarItem.NOTIFICATIONS,
|
||||
)
|
||||
|
||||
private val DrawerYouItems: List<NavBarItem> =
|
||||
listOf(
|
||||
NavBarItem.PROFILE,
|
||||
NavBarItem.MY_LISTS,
|
||||
NavBarItem.BOOKMARKS,
|
||||
NavBarItem.WEB_BOOKMARKS,
|
||||
NavBarItem.DRAFTS,
|
||||
NavBarItem.SCHEDULED_POSTS,
|
||||
NavBarItem.INTEREST_SETS,
|
||||
NavBarItem.FAVORITE_ALGO_FEEDS,
|
||||
NavBarItem.BLOSSOM_DATA,
|
||||
NavBarItem.EMOJI_PACKS,
|
||||
NavBarItem.WALLET,
|
||||
NavBarItem.NOSTR_SIGNER,
|
||||
)
|
||||
|
||||
private val DrawerFeedsItems: List<NavBarItem> =
|
||||
listOfNotNull(
|
||||
NavBarItem.ARTICLES,
|
||||
NavBarItem.PICTURES,
|
||||
NavBarItem.SHORTS,
|
||||
NavBarItem.LONGS,
|
||||
NavBarItem.PODCAST_EPISODES,
|
||||
NavBarItem.PODCASTS,
|
||||
NavBarItem.MUSIC_TRACKS,
|
||||
NavBarItem.MUSIC_PLAYLISTS,
|
||||
NavBarItem.POLLS,
|
||||
NavBarItem.PRODUCTS,
|
||||
NavBarItem.WORKOUTS,
|
||||
NavBarItem.GIT_REPOSITORIES,
|
||||
NavBarItem.HIGHLIGHTS,
|
||||
NavBarItem.LIVE_STREAMS,
|
||||
NavBarItem.NESTS,
|
||||
NavBarItem.COMMUNITIES,
|
||||
NavBarItem.PUBLIC_CHATS,
|
||||
NavBarItem.RELAY_GROUPS,
|
||||
NavBarItem.CONCORD,
|
||||
NavBarItem.GEOHASH_CHATS,
|
||||
NavBarItem.CALENDARS,
|
||||
NavBarItem.CALENDAR_COLLECTIONS,
|
||||
NavBarItem.SOFTWARE_APPS,
|
||||
// Favorites can be pinned as inline tabs that render on a cross-process surface
|
||||
// (SurfaceControlViewHost), which needs API 30+. Gate the whole grid on R+ for that reason.
|
||||
NavBarItem.FAVORITE_APPS.takeIf { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R },
|
||||
NavBarItem.NAPPLETS,
|
||||
NavBarItem.NSITES,
|
||||
NavBarItem.FOLLOW_PACKS,
|
||||
NavBarItem.BADGES,
|
||||
NavBarItem.EMOJI_SETS,
|
||||
)
|
||||
|
||||
val DrawerSections: List<DrawerSection> =
|
||||
listOf(
|
||||
DrawerSection(DrawerSectionId.YOU, R.string.drawer_section_you, MaterialSymbols.AccountCircle, DrawerYouItems),
|
||||
DrawerSection(DrawerSectionId.NAVIGATE, R.string.drawer_section_navigate, MaterialSymbols.Home, DrawerNavigateItems),
|
||||
DrawerSection(DrawerSectionId.FEEDS, R.string.drawer_section_feeds, MaterialSymbols.Subscriptions, DrawerFeedsItems),
|
||||
DrawerSection(DrawerSectionId.CREATE, R.string.drawer_section_create, MaterialSymbols.Edit, emptyList(), hasFixedRows = true),
|
||||
DrawerSection(DrawerSectionId.SYSTEM, R.string.drawer_section_system, MaterialSymbols.Settings, listOf(NavBarItem.SETTINGS), hasFixedRows = true),
|
||||
)
|
||||
|
||||
/**
|
||||
* Catalog ids deliberately absent from every [DrawerSections] list, with the reason. Only Favorite
|
||||
* Apps qualifies: [DrawerFeedsItems] gates it on API 30+ (its inline tabs need SurfaceControlViewHost),
|
||||
* so on older devices the row simply doesn't exist. DrawerSectionsTest allows exactly these to be
|
||||
* missing, and fails on anything else — that's what keeps a newly added destination from silently
|
||||
* skipping both the drawer and its settings screen.
|
||||
*/
|
||||
val SdkGatedDrawerItems: Set<NavBarItem> = setOf(NavBarItem.FAVORITE_APPS)
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.navigation.navs
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/** How long to wait for the IME inset to reach zero before navigating anyway. */
|
||||
const val IME_SETTLE_TIMEOUT_MS = 700L
|
||||
|
||||
/**
|
||||
* Waits for the soft keyboard to be fully off screen. Installed on [Nav] so that every navigation
|
||||
* in the app serializes the IME and window animations instead of overlapping them.
|
||||
*
|
||||
* Navigating while the keyboard is up races the window animation against the IME's close animation.
|
||||
* On release builds — fast enough that the window animation wins — the IME
|
||||
* [WindowInsetsAnimationCompat][androidx.core.view.WindowInsetsAnimationCompat] is cancelled before
|
||||
* its terminal (zero) frame reaches Compose. `WindowInsets.ime` is a single app-wide holder, so it
|
||||
* stays "animating" and every `Modifier.imePadding()` in the app — not just the screen being left —
|
||||
* freezes at the keyboard height until some later inset pass happens to rebalance it.
|
||||
*
|
||||
* This is not a composer-screen problem, which is why it lives here rather than in the screens.
|
||||
* Any destination that can hold focus in a text field can strand the padding on the way out, by any
|
||||
* exit: a back gesture, a top-bar button, a bottom-nav tab, or tapping a result. Search is the
|
||||
* clearest case — it focuses its field on arrival, so the keyboard is already up before the user
|
||||
* has done anything, and every way out of it is a navigation.
|
||||
*/
|
||||
fun interface ImeSettler {
|
||||
suspend fun settle()
|
||||
|
||||
companion object {
|
||||
/** For [EmptyNav] and previews, where there is no window to read insets from. */
|
||||
val None = ImeSettler { }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the same animated `WindowInsets.ime` that drives `Modifier.imePadding()`, so the settler
|
||||
* and the padding can never disagree about whether the keyboard is gone.
|
||||
*
|
||||
* Focus is cleared before hiding so nothing re-requests the IME as it retracts. The wait is bounded
|
||||
* by [IME_SETTLE_TIMEOUT_MS] — if the inset never reports zero, which is precisely the failure this
|
||||
* guards against, navigation still proceeds rather than stranding the user on the screen.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberImeSettler(): ImeSettler {
|
||||
val density = LocalDensity.current
|
||||
val imeInsets = WindowInsets.ime
|
||||
val keyboard = LocalSoftwareKeyboardController.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
|
||||
return remember(density, imeInsets, keyboard, focusManager) {
|
||||
ImeSettler {
|
||||
if (imeInsets.getBottom(density) > 0) {
|
||||
focusManager.clearFocus(true)
|
||||
keyboard?.hide()
|
||||
withTimeoutOrNull(IME_SETTLE_TIMEOUT_MS) {
|
||||
snapshotFlow { imeInsets.getBottom(density) }.first { it <= 0 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,13 @@ import kotlin.reflect.KClass
|
||||
class Nav(
|
||||
val controller: NavHostController,
|
||||
override val navigationScope: CoroutineScope,
|
||||
/**
|
||||
* Awaited before every transition below. Leaving a screen while the soft keyboard is still
|
||||
* animating strands `imePadding()` app-wide; see [ImeSettler]. Every in-app navigation goes
|
||||
* through this class, so this is the one place that has to get it right — no screen, top bar
|
||||
* or back handler needs to think about the keyboard on its way out.
|
||||
*/
|
||||
private val ime: ImeSettler = ImeSettler.None,
|
||||
) : INav {
|
||||
override val drawerState = DrawerState(DrawerValue.Closed)
|
||||
|
||||
@@ -63,6 +70,7 @@ class Nav(
|
||||
|
||||
override fun nav(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
if (getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
}
|
||||
@@ -71,6 +79,7 @@ class Nav(
|
||||
|
||||
override fun nav(computeRoute: suspend () -> Route?) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
val route = computeRoute()
|
||||
if (route != null && getRouteWithArguments(route::class, controller) != route) {
|
||||
controller.navigate(route)
|
||||
@@ -80,6 +89,7 @@ class Nav(
|
||||
|
||||
override fun newStack(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(route) {
|
||||
inclusive = true
|
||||
@@ -91,6 +101,7 @@ class Nav(
|
||||
|
||||
override fun navBottomBar(route: Route) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
// Clear sibling bottom-nav entries but keep Home (the start
|
||||
// destination) below, so back-swipe from any tab returns to
|
||||
@@ -110,7 +121,28 @@ class Nav(
|
||||
}
|
||||
// Mark this entry as a tab root: hides the back arrow in canPop
|
||||
// and skips the horizontal slide in composableFromEnd.
|
||||
controller.getBackStackEntry(route).savedStateHandle[BOTTOM_NAV_ROOT_KEY] = true
|
||||
// saveState/restoreState are keyed by DESTINATION, and every pinned tab of one kind shares a
|
||||
// single destination — all web apps are `Route.WebApp/{url}`, all pinned chats their own one
|
||||
// pattern. So the restore above can hand back a *sibling* tab's saved entry: with two web apps
|
||||
// pinned, tapping the second one landed on the first one's URL, and the lookup below then threw
|
||||
// `No destination with route …WebApp/<url> is on the NavController's back stack`.
|
||||
//
|
||||
// When the entry we asked for isn't there, take the tab fresh (no restoreState, and no
|
||||
// launchSingleTop — the top is the sibling we do not want to reuse). Its saved scroll/ViewModel
|
||||
// state is not recoverable in that case, but the user lands on the tab they tapped. Tabs whose
|
||||
// destination nothing else shares still restore normally, which is what this is here for.
|
||||
val entry =
|
||||
runCatching { controller.getBackStackEntry(route) }.getOrNull()
|
||||
?: run {
|
||||
controller.navigate(route) {
|
||||
popUpTo(Route.Home) {
|
||||
inclusive = false
|
||||
saveState = true
|
||||
}
|
||||
}
|
||||
runCatching { controller.getBackStackEntry(route) }.getOrNull()
|
||||
}
|
||||
entry?.savedStateHandle?.set(BOTTOM_NAV_ROOT_KEY, true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +181,7 @@ class Nav(
|
||||
|
||||
override fun popBack() {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigateUp()
|
||||
}
|
||||
}
|
||||
@@ -159,6 +192,7 @@ class Nav(
|
||||
klass: KClass<T>,
|
||||
) {
|
||||
navigationScope.launch {
|
||||
ime.settle()
|
||||
controller.navigate(route) {
|
||||
popUpTo(klass) { inclusive = true }
|
||||
}
|
||||
|
||||
+3
-2
@@ -29,9 +29,10 @@ import androidx.navigation.compose.rememberNavController
|
||||
fun rememberNav(): Nav {
|
||||
val navController = rememberNavController()
|
||||
val scope = rememberCoroutineScope()
|
||||
val ime = rememberImeSettler()
|
||||
|
||||
return remember(navController, scope) {
|
||||
Nav(navController, scope)
|
||||
return remember(navController, scope, ime) {
|
||||
Nav(navController, scope, ime)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -457,6 +457,8 @@ sealed class Route {
|
||||
|
||||
@Serializable object BottomBarSettings : Route()
|
||||
|
||||
@Serializable object DrawerSettings : Route()
|
||||
|
||||
@Serializable object HomeTabsSettings : Route()
|
||||
|
||||
@Serializable object ProfileUiSettings : Route()
|
||||
@@ -832,6 +834,10 @@ sealed class Route {
|
||||
val communityId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable data class ConcordInviteLinks(
|
||||
val communityId: String,
|
||||
) : Route()
|
||||
|
||||
@Serializable object ConcordCreate : Route()
|
||||
|
||||
// Deep-link target for a Concord invite link (naddr#fragment). Opens the join flow.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.ui.note
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionRow
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionSection
|
||||
import com.vitorpamplona.amethyst.ui.components.cachedTranslation
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.note.types.displayedNoteText
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Both texts of a translated note, held while the user picks which one to copy. */
|
||||
@Immutable
|
||||
data class CopyTextChoice(
|
||||
val original: String,
|
||||
val translated: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The "Copy Text" flow shared by every menu that copies an event's text.
|
||||
*
|
||||
* The copy menus sit far from the `TranslatableRichTextViewer` that rendered (and possibly
|
||||
* translated) the note, so instead of plumbing the translated string down the hierarchy this
|
||||
* flow re-derives it from [cachedTranslation]: the process-wide translation cache keyed by
|
||||
* (content, language settings). By the time any copy menu is reachable the note has been
|
||||
* rendered, which is what populated that cache — so a hit means the user is looking at a
|
||||
* translation and gets a chooser (Copy Original / Copy Translated); a miss copies directly.
|
||||
*
|
||||
* What gets copied — and what the cache is keyed on — is [displayedNoteText], the same string
|
||||
* the viewer rendered, not the raw event content: a NIP-14 subject is part of what the user is
|
||||
* reading and of what was translated.
|
||||
*
|
||||
* Returns the click handler for the menu entry, taking the note the menu belongs to and the
|
||||
* version of it the screen is showing (the same note unless the post was edited — the body
|
||||
* comes from the version, the subject from the note itself, exactly as the viewer composes
|
||||
* them). [onCopied] runs after the text lands on the
|
||||
* clipboard, [onDismiss] when the chooser is cancelled without copying **or** when the note
|
||||
* can't be decrypted at all (a read-only account, a refused signer) so the menu still closes
|
||||
* instead of hanging on a copy that will never happen. Callers must keep their menu in
|
||||
* composition until one of the two runs, because the chooser dialog is emitted from this
|
||||
* composable.
|
||||
*/
|
||||
@Composable
|
||||
fun copyNoteTextAction(
|
||||
accountViewModel: AccountViewModel,
|
||||
onCopied: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
): (note: Note, versionShown: Note) -> Unit {
|
||||
val clipboardManager = LocalClipboard.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val choice = remember { mutableStateOf<CopyTextChoice?>(null) }
|
||||
|
||||
val copy: (String) -> Unit = { text ->
|
||||
scope.launch {
|
||||
clipboardManager.setText(text)
|
||||
onCopied()
|
||||
}
|
||||
}
|
||||
|
||||
choice.value?.let { options ->
|
||||
CopyTextChooserDialog(
|
||||
onCopyOriginal = {
|
||||
choice.value = null
|
||||
copy(options.original)
|
||||
},
|
||||
onCopyTranslated = {
|
||||
choice.value = null
|
||||
copy(options.translated)
|
||||
},
|
||||
onDismiss = {
|
||||
choice.value = null
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return { note, versionShown ->
|
||||
accountViewModel.decryptOrNull(versionShown) { decrypted ->
|
||||
if (decrypted == null) {
|
||||
onDismiss()
|
||||
} else {
|
||||
val original = displayedNoteText(note, decrypted)
|
||||
val translated = cachedTranslation(original, accountViewModel)
|
||||
if (translated == null) {
|
||||
copy(original)
|
||||
} else {
|
||||
choice.value = CopyTextChoice(original, translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CopyTextChooserDialog(
|
||||
onCopyOriginal: () -> Unit,
|
||||
onCopyTranslated: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
M3ActionDialog(
|
||||
title = stringRes(R.string.copy_text),
|
||||
onDismiss = onDismiss,
|
||||
) {
|
||||
M3ActionSection {
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.ContentCopy,
|
||||
text = stringRes(R.string.copy_text_original),
|
||||
onClick = onCopyOriginal,
|
||||
)
|
||||
M3ActionRow(
|
||||
icon = MaterialSymbols.Translate,
|
||||
text = stringRes(R.string.copy_text_translated),
|
||||
onClick = onCopyTranslated,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.model.textNoteModifications
|
||||
import com.vitorpamplona.amethyst.ui.components.util.setText
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeEditDraftTo
|
||||
@@ -299,20 +300,31 @@ fun CardBody(
|
||||
)
|
||||
}
|
||||
|
||||
// "Copy Text" copies the version on screen: an edited post renders its newest modification
|
||||
// by default (EditState.updateModifications), and the 3-dot menu already copies that one.
|
||||
// Reading `edits` is a hard-referenced in-memory fold, so no cache scan here.
|
||||
val noteVersionToCopy = remember(note) { note.textNoteModifications().lastOrNull() ?: note }
|
||||
|
||||
// When the rendered note was translated, tapping Copy Text opens a chooser
|
||||
// (Copy Original / Copy Translated) on top of this popup; the popup stays up
|
||||
// until the flow resolves so the chooser survives in composition.
|
||||
val copyNoteText =
|
||||
copyNoteTextAction(
|
||||
accountViewModel = accountViewModel,
|
||||
onCopied = {
|
||||
showToast(R.string.copied_note_text_to_clipboard)
|
||||
onDismiss()
|
||||
},
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.width(IntrinsicSize.Min)) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
NoteQuickActionItem(
|
||||
icon = MaterialSymbols.ContentCopy,
|
||||
label = stringRes(R.string.quick_action_copy_text),
|
||||
) {
|
||||
accountViewModel.decrypt(note) {
|
||||
scope.launch {
|
||||
clipboardManager.setText(it)
|
||||
showToast(R.string.copied_note_text_to_clipboard)
|
||||
}
|
||||
}
|
||||
|
||||
onDismiss()
|
||||
copyNoteText(note, noteVersionToCopy)
|
||||
}
|
||||
VerticalDivider(color = primaryLight)
|
||||
NoteQuickActionItem(
|
||||
|
||||
+12
-4
@@ -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(
|
||||
|
||||
+1
-1
@@ -61,7 +61,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_MARGIN_PX in QrCodeDrawer.kt) is a
|
||||
// A cap, not a fixed size: QrCodeDrawer's own quiet zone (QR_QUIET_ZONE_MODULES in QrCodeDrawer.kt) is a
|
||||
// fixed pixel count subtracted from raw size.width, so its share of the tile grows as density
|
||||
// falls. Hard-sizing this call to a small dp value starved long-form naddr payloads of scannable
|
||||
// resolution on low-density screens. Deriving the size from the available column width keeps
|
||||
|
||||
@@ -24,10 +24,13 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlPdf
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
import com.vitorpamplona.amethyst.ui.components.FileAttachmentCard
|
||||
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
|
||||
import com.vitorpamplona.amethyst.ui.components.ZoomableContentView
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
@@ -43,50 +46,103 @@ fun FileHeaderDisplay(
|
||||
) {
|
||||
val event = (note.event as? FileHeaderEvent) ?: return
|
||||
val fullUrl = event.url() ?: return
|
||||
val mimeType = remember(note) { event.mimeType() }
|
||||
val content = remember(note) { event.toMediaContent(note, fullUrl, mimeType) }
|
||||
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val blurHash = event.blurhash()
|
||||
val thumbHash = event.thumbhash()
|
||||
val hash = event.hash()
|
||||
val dimensions = event.dimensions()
|
||||
val description = event.content.ifEmpty { null } ?: event.alt()
|
||||
val isImage = event.mimeType()?.startsWith("image/") == true || RichTextParser.isImageUrl(fullUrl)
|
||||
val uri = note.toNostrUri()
|
||||
val mimeType = event.mimeType()
|
||||
|
||||
if (isImage) {
|
||||
MediaUrlImage(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
} else {
|
||||
MediaUrlVideo(
|
||||
url = fullUrl,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// The sensitivity gate wraps both branches: a content warning is about the file, not about
|
||||
// which viewer happens to render it, so an NSFW-tagged archive stays behind the same gate.
|
||||
SensitivityWarning(note = note, accountViewModel = accountViewModel) {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
if (content == null) {
|
||||
FileHeaderAttachmentCard(event, fullUrl, mimeType)
|
||||
} else {
|
||||
ZoomableContentView(
|
||||
content = content,
|
||||
roundedCorner = roundedCorner,
|
||||
contentScale = contentScale,
|
||||
accountViewModel = accountViewModel,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the viewer for a kind-1063 header, or **null** when no viewer can show the blob.
|
||||
*
|
||||
* Kind 1063 is a *generic* file container — its `m` tag can name any type, so unlike a NIP-71
|
||||
* video event the kind itself asserts nothing about how to render the payload. A null here means
|
||||
* the file belongs in [FileHeaderAttachmentCard] rather than being pushed into the video player.
|
||||
*/
|
||||
internal fun FileHeaderEvent.toMediaContent(
|
||||
note: Note,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
): BaseMediaContent? {
|
||||
val blurHash = blurhash()
|
||||
val thumbHash = thumbhash()
|
||||
val hash = hash()
|
||||
val dimensions = dimensions()
|
||||
val description = fileDescription()
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
return when (RichTextParser.classifyMedia(url, mimeType)) {
|
||||
MediaContentKind.IMAGE ->
|
||||
MediaUrlImage(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.VIDEO ->
|
||||
MediaUrlVideo(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
authorName = note.author?.toBestDisplayName(),
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
MediaContentKind.PDF ->
|
||||
MediaUrlPdf(
|
||||
url = url,
|
||||
description = description,
|
||||
hash = hash,
|
||||
blurhash = blurHash,
|
||||
dim = dimensions,
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
thumbhash = thumbHash,
|
||||
)
|
||||
|
||||
null -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** The link card a kind-1063 header falls back to when [toMediaContent] returns null. */
|
||||
@Composable
|
||||
internal fun FileHeaderAttachmentCard(
|
||||
event: FileHeaderEvent,
|
||||
url: String,
|
||||
mimeType: String?,
|
||||
) {
|
||||
val description = remember(event) { event.fileDescription() }
|
||||
val sizeInBytes = remember(event) { event.size()?.toLong() }
|
||||
|
||||
FileAttachmentCard(
|
||||
url = url,
|
||||
description = description,
|
||||
mimeType = mimeType,
|
||||
sizeInBytes = sizeInBytes,
|
||||
)
|
||||
}
|
||||
|
||||
/** The human-facing name of the file: NIP-94 `content` when present, else the `alt` tag. */
|
||||
private fun FileHeaderEvent.fileDescription(): String? = content.ifEmpty { null } ?: alt()
|
||||
|
||||
@@ -65,6 +65,7 @@ import coil3.compose.AsyncImage
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.ClickableTextPrimary
|
||||
import com.vitorpamplona.amethyst.commons.util.prettyMime
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
|
||||
import com.vitorpamplona.amethyst.model.Note
|
||||
@@ -766,27 +767,6 @@ fun RenderSoftwareAsset(
|
||||
}
|
||||
}
|
||||
|
||||
internal fun prettyMime(mime: String): String =
|
||||
when (mime) {
|
||||
"application/vnd.android.package-archive" -> "APK"
|
||||
"application/vnd.apple.ipa" -> "IPA"
|
||||
"application/x-apple-diskimage" -> "DMG"
|
||||
"application/vnd.apple.installer+xml" -> "PKG"
|
||||
"application/x-msi" -> "MSI"
|
||||
"application/vnd.appimage" -> "AppImage"
|
||||
"application/vnd.flatpak" -> "Flatpak"
|
||||
"application/vnd.oci.image.manifest.v1+json" -> "OCI"
|
||||
"application/x-executable" -> "ELF"
|
||||
"application/x-mach-binary" -> "Mach-O"
|
||||
"application/vnd.microsoft.portable-executable" -> "EXE"
|
||||
"application/vsix" -> "VSIX"
|
||||
"application/x-chrome-extension" -> "CRX"
|
||||
"application/x-xpinstall" -> "XPI"
|
||||
"application/wasm" -> "WASM"
|
||||
"application/webbundle" -> "Web Bundle"
|
||||
else -> mime
|
||||
}
|
||||
|
||||
internal fun formatBytes(bytes: Long): String {
|
||||
if (bytes < 1024L) return "$bytes B"
|
||||
val kb = bytes / 1024.0
|
||||
|
||||
@@ -69,6 +69,27 @@ enum class ReplyRenderType {
|
||||
NONE,
|
||||
}
|
||||
|
||||
/**
|
||||
* The text [RenderTextEvent] puts on screen for [note] given its decrypted [body]: a NIP-14
|
||||
* subject the body doesn't already repeat is prepended to it.
|
||||
*
|
||||
* This is the exact string handed to `TranslatableRichTextViewer`, so it is also the key the
|
||||
* translation cache stores the result under. The copy-text menus look their translation up by
|
||||
* the same function — keying on the raw body instead would miss the entry for every
|
||||
* subject-carrying note and silently copy the untranslated text.
|
||||
*/
|
||||
fun displayedNoteText(
|
||||
note: Note,
|
||||
body: String,
|
||||
): String {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null }
|
||||
return if (subject != null && !body.contains(subject, ignoreCase = true)) {
|
||||
"$subject\n\n$body"
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RenderTextEvent(
|
||||
note: Note,
|
||||
@@ -177,15 +198,7 @@ fun RenderTextEvent(
|
||||
body
|
||||
}
|
||||
|
||||
val eventContent =
|
||||
remember(newBody) {
|
||||
val subject = (note.event as? TextNoteEvent)?.subject()?.ifBlank { null }
|
||||
if (!subject.isNullOrBlank() && !newBody.contains(subject, ignoreCase = true)) {
|
||||
"$subject\n\n$newBody"
|
||||
} else {
|
||||
newBody
|
||||
}
|
||||
}
|
||||
val eventContent = remember(newBody) { displayedNoteText(note, newBody) }
|
||||
|
||||
// A boosted note inside a zap/nutzap/onchain activity card is always shown as a
|
||||
// compact 2-line preview, even when the logged-in user is only a zap-split
|
||||
|
||||
@@ -43,6 +43,7 @@ import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -88,7 +89,9 @@ fun VideoDisplay(
|
||||
val content: BaseMediaContent =
|
||||
remember(note) {
|
||||
val description = videoEvent.content.ifBlank { null } ?: event.alt()
|
||||
val isImage = imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url)
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE
|
||||
val uri = note.toNostrUri()
|
||||
|
||||
if (isImage) {
|
||||
|
||||
@@ -26,6 +26,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.commons.richtext.BaseMediaContent
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaContentKind
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage
|
||||
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
|
||||
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
|
||||
@@ -55,7 +56,9 @@ fun JustVideoDisplay(
|
||||
val imeta = videoEvent.imetaTags().getOrNull(0) ?: return
|
||||
val isSensitive = remember(note) { event.isSensitiveOrNSFW() }
|
||||
val reasons = remember(note) { collectContentWarningReasons(event) }
|
||||
val isImage = remember(note) { imeta.mimeType?.startsWith("image/") == true || RichTextParser.isImageUrl(imeta.url) }
|
||||
// A NIP-71 event asserts its own type, so only an explicit image imeta diverts to the
|
||||
// viewer; an unclassifiable one still belongs in the player. See classifyMedia.
|
||||
val isImage = remember(note) { RichTextParser.classifyMedia(imeta.url, imeta.mimeType) == MediaContentKind.IMAGE }
|
||||
|
||||
val content by
|
||||
remember(note) {
|
||||
|
||||
+34
-1
@@ -93,6 +93,7 @@ import com.vitorpamplona.amethyst.ui.actions.MediaSaverToDisk
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.amethyst.ui.components.toasts.ToastManager
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification
|
||||
import com.vitorpamplona.amethyst.ui.note.ZapraiserStatus
|
||||
@@ -1585,6 +1586,29 @@ class AccountViewModel(
|
||||
account.decryptContent(note)?.let { onReady(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* [decrypt] that always answers: [onReady] gets null when the content can't be read — a
|
||||
* read-only account holding no key, a DM this account isn't part of, or a signer that
|
||||
* refused/timed out. [decrypt] stays silent in those cases, which strands callers that must
|
||||
* finish either way (a menu that only closes once the copy resolves, say).
|
||||
*/
|
||||
fun decryptOrNull(
|
||||
note: Note,
|
||||
onReady: (String?) -> Unit,
|
||||
) = launchSigner {
|
||||
val decrypted =
|
||||
try {
|
||||
account.decryptContent(note)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
// launchSigner still gets the exception to toast/log the signer failure.
|
||||
onReady(null)
|
||||
throw e
|
||||
}
|
||||
onReady(decrypted)
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an action that has both a tracked and a direct broadcast variant,
|
||||
* picking the path the user selected via the "Tracked broadcasts" setting.
|
||||
@@ -1986,6 +2010,15 @@ class AccountViewModel(
|
||||
|
||||
fun bottomBarItemsFlow(): StateFlow<List<BottomBarEntry>> = account.settings.syncedSettings.navigation.bottomBarItems
|
||||
|
||||
fun hiddenDrawerItemsFlow(): StateFlow<Set<NavBarItem>> = account.settings.syncedSettings.navigation.hiddenDrawerItems
|
||||
|
||||
/** Same ordering contract as [changeBottomBarItems]: apply on the caller's thread, publish off it. */
|
||||
fun changeHiddenDrawerItems(items: Set<NavBarItem>) {
|
||||
if (account.applyHiddenDrawerItems(items)) {
|
||||
launchSigner { account.sendNewAppSpecificData() }
|
||||
}
|
||||
}
|
||||
|
||||
fun changeBottomBarItems(items: List<BottomBarEntry>) {
|
||||
// Apply to the reactive flow synchronously on the caller (UI) thread so rapid edits stay
|
||||
// ordered — launchSigner dispatches on a multi-threaded pool, so wrapping the emit too would
|
||||
@@ -2096,7 +2129,7 @@ class AccountViewModel(
|
||||
|
||||
fun checkGetOrCreateUser(key: HexKey): User? = LocalCache.checkGetOrCreateUser(key)
|
||||
|
||||
override fun getOrCreateUser(hex: HexKey): User = LocalCache.getOrCreateUser(hex)
|
||||
override fun getOrCreateUser(pubkey: HexKey): User = LocalCache.getOrCreateUser(pubkey)
|
||||
|
||||
fun getUserIfExists(hex: HexKey): User? = LocalCache.getUserIfExists(hex)
|
||||
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+1
-35
@@ -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,
|
||||
|
||||
+1
-3
@@ -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()
|
||||
}
|
||||
|
||||
+1
-3
@@ -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
|
||||
|
||||
+53
@@ -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)
|
||||
}
|
||||
+2
-8
@@ -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.
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+2
-2
@@ -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()
|
||||
|
||||
+50
-18
@@ -167,11 +167,19 @@ fun ConcordChannelListScreen(
|
||||
|
||||
// Channel create/rename/delete are gated on MANAGE_CHANNELS (or owner) — the same predicate the
|
||||
// fold enforces, so an unauthorized action would be a silent no-op we shouldn't even offer.
|
||||
// Rank alone isn't enough on a split epoch: publishing any Control edition also takes the
|
||||
// control_root (CORD-02 §2), which a freshly promoted staffer may not hold yet (CORD-04 §3),
|
||||
// so the affordance waits for the key too.
|
||||
// hasPermission, never effectivePermissions: the latter reads the roles alone, so a banned
|
||||
// moderator kept seeing every control here. The editions they authored were dropped by everyone's
|
||||
// fold, which made these buttons silently no-op — worse than absent, and the same trap this file
|
||||
// already avoids for the Roles… menu.
|
||||
val canManageChannels =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_CHANNELS)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// channelIdHex == null → create; else → rename that channel.
|
||||
var channelEditor by remember { mutableStateOf<ConcordChannelEditor?>(null) }
|
||||
@@ -234,10 +242,21 @@ fun ConcordChannelListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Rank + the Control write key (CORD-02 §2), like [canManageChannels] above.
|
||||
val canEdit =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.effectivePermissions(account.signer.pubKey).has(ConcordPermissions.MANAGE_METADATA)
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.MANAGE_METADATA)
|
||||
} == true &&
|
||||
session?.controlPlaneKeys()?.canWrite == true
|
||||
|
||||
// Minting an invite hands out a working key to the community, so it takes
|
||||
// CREATE_INVITE like any other privileged action. This button used to be the one
|
||||
// control on the screen with no gate at all.
|
||||
val canInvite =
|
||||
state?.authority?.let {
|
||||
it.isOwner(account.signer.pubKey) ||
|
||||
it.hasPermission(account.signer.pubKey, ConcordPermissions.CREATE_INVITE)
|
||||
} == true
|
||||
|
||||
IconButton(onClick = { nav.nav(Route.ConcordMembers(communityId)) }) {
|
||||
@@ -248,22 +267,24 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.Edit, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_edit_title))
|
||||
}
|
||||
}
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
if (canInvite) {
|
||||
IconButton(
|
||||
enabled = !minting,
|
||||
onClick = {
|
||||
minting = true
|
||||
scope.launch {
|
||||
try {
|
||||
inviteLink = account.concord.mintConcordInvite(communityId)
|
||||
} finally {
|
||||
// Always clear the flag — a thrown mint would otherwise leave the
|
||||
// button disabled until the screen is recreated.
|
||||
minting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
},
|
||||
) {
|
||||
SymbolIcon(symbol = MaterialSymbols.PersonAdd, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_action))
|
||||
}
|
||||
}
|
||||
|
||||
// Overflow, mirroring the NIP-29 relay-group top bar: destructive membership
|
||||
@@ -273,6 +294,17 @@ fun ConcordChannelListScreen(
|
||||
SymbolIcon(symbol = MaterialSymbols.MoreVert, contentDescription = stringRes(com.vitorpamplona.amethyst.R.string.more_options))
|
||||
}
|
||||
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
|
||||
// Deliberately not gated on CREATE_INVITE, unlike minting: the links listed
|
||||
// there are this account's own, authored by link-signer keys only we hold.
|
||||
// Gating on the bit would mean a demoted admin could no longer retire the
|
||||
// links they had already handed out — exactly when that matters most.
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringRes(com.vitorpamplona.amethyst.R.string.concord_invite_links_action)) },
|
||||
onClick = {
|
||||
menuOpen = false
|
||||
nav.nav(Route.ConcordInviteLinks(communityId))
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user