mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/shortpost-lock-list-selection-3pg1s2
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.
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -220,7 +220,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
|
||||
@@ -26,8 +26,12 @@ env:
|
||||
# bundle deps — that fights jpackage's self-contained JRE (libjvm.so has
|
||||
# $ORIGIN RPATH so ldd can't resolve it standalone). appimagetool only
|
||||
# embeds the AppDir as-is, which is what we actually want.
|
||||
APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
#
|
||||
# Both arch binaries come from the same appimagetool release so their SHA256
|
||||
# values move in lockstep on version bumps.
|
||||
APPIMAGETOOL_VERSION: '1.9.0'
|
||||
APPIMAGETOOL_SHA256_X86_64: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1
|
||||
APPIMAGETOOL_SHA256_AARCH64: 04f45ea45b5aa07bb2b071aed9dbf7a5185d3953b11b47358c1311f11ea94a96
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -38,11 +42,32 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Linux legs run on x64 and arm64 GitHub-hosted runners (the
|
||||
# ubuntu-24.04-arm label is a standard free public-repo runner as of
|
||||
# early 2025). Windows arm64 uses windows-11-arm, added to the free
|
||||
# public-repo runner catalogue in 2025 (4 vCPU / 16 GB / arm64).
|
||||
# jpackage / jlink / Compose Multiplatform 1.11 all produce
|
||||
# host-native artifacts — no cross-compilation needed.
|
||||
#
|
||||
# The arm64 Windows leg builds the portable .zip ONLY — no MSI.
|
||||
# jpackage --type msi shells out to WiX 3's heat/candle/light, and the
|
||||
# windows-11-arm runner image ships no WiX (the windows-latest image
|
||||
# has WiX 3.14 preinstalled, which is why the x64 leg can package an
|
||||
# MSI). Installing it here would mean pulling an archived, x86-only
|
||||
# toolchain (wixtoolset/wix3 was archived in Feb 2025; WiX 4+ dropped
|
||||
# the candle/light CLI that JDK 21's jpackage requires) into the job
|
||||
# that publishes signed release assets. The portable zip is the
|
||||
# documented Windows install path for amy/geode already, so arm64
|
||||
# Windows users get that until either the runner image gains WiX or
|
||||
# jpackage learns the WiX 4+ CLI.
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "packageReleaseDmg" }
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "packageReleaseMsi createReleaseDistributable" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "createReleaseDistributable" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "packageReleaseDeb packageReleaseRpm" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux-portable, tasks: "createReleaseAppImage createReleaseDistributable" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60 # linux-portable leg also downloads the freedesktop runtime + builds the Flatpak bundle
|
||||
defaults:
|
||||
@@ -53,7 +78,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -93,13 +118,21 @@ jobs:
|
||||
set -euo pipefail
|
||||
# appimagetool 1.9.0 validates the .desktop file via desktop-file-validate.
|
||||
sudo apt-get update && sudo apt-get install -y desktop-file-utils
|
||||
curl -fsSL --retry 3 "$APPIMAGETOOL_URL" -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
actual=$(sha256sum desktopApp/packaging/appimage/appimagetool-x86_64.AppImage | awk '{print $1}')
|
||||
if [[ "$actual" != "$APPIMAGETOOL_SHA256" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch. Expected $APPIMAGETOOL_SHA256, got $actual"
|
||||
# Map runner arch → upstream AppImage suffix (x86_64 / aarch64).
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) TOOL_ARCH=x86_64 ; EXPECTED_SHA="$APPIMAGETOOL_SHA256_X86_64" ;;
|
||||
arm64) TOOL_ARCH=aarch64; EXPECTED_SHA="$APPIMAGETOOL_SHA256_AARCH64" ;;
|
||||
*) echo "::error::unsupported arch for AppImage: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
URL="https://github.com/AppImage/appimagetool/releases/download/${APPIMAGETOOL_VERSION}/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
DEST="desktopApp/packaging/appimage/appimagetool-${TOOL_ARCH}.AppImage"
|
||||
curl -fsSL --retry 3 "$URL" -o "$DEST"
|
||||
actual=$(sha256sum "$DEST" | awk '{print $1}')
|
||||
if [[ "$actual" != "$EXPECTED_SHA" ]]; then
|
||||
echo "::error::appimagetool SHA256 mismatch for $TOOL_ARCH. Expected $EXPECTED_SHA, got $actual"
|
||||
exit 1
|
||||
fi
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
chmod +x "$DEST"
|
||||
|
||||
# Flatpak tooling + the freedesktop runtime/sdk the manifest pins
|
||||
# (runtime-version is greped from the manifest so this never drifts).
|
||||
@@ -203,17 +236,32 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# jpackage --type deb only auto-generates Depends from dpkg-shlibdeps
|
||||
# against the bundled JRE under lib/runtime/, NOT the app payload under
|
||||
# lib/app/. libskiko-linux-arm64.so has libEGL.so.1 in DT_NEEDED (unlike
|
||||
# the x64 skiko which only links libGL.so.1), so a minimal aarch64
|
||||
# install without EGL crashes at startup with:
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# Rewrite the arm64 .deb to add libegl1 to Depends. x64 .deb is untouched.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
if: matrix.family == 'linux' && matrix.arch == 'arm64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Build portable archives (windows + linux-portable)
|
||||
if: matrix.family == 'windows' || matrix.family == 'linux-portable'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VER="${{ steps.ver.outputs.version }}"
|
||||
ARCH="${{ matrix.arch }}"
|
||||
APP="desktopApp/build/compose/binaries/main-release/app"
|
||||
mkdir -p desktopApp/build/portable
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-x64.zip" Amethyst/ )
|
||||
( cd "$APP" && 7z a -tzip "../../../../portable/amethyst-desktop-${VER}-windows-${ARCH}.zip" Amethyst/ )
|
||||
else
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-x64.tar.gz" Amethyst/ )
|
||||
( cd "$APP" && tar czf "../../../../portable/amethyst-desktop-${VER}-linux-${ARCH}.tar.gz" Amethyst/ )
|
||||
fi
|
||||
|
||||
# Flatpak bundle: wraps the same createReleaseDistributable tree the
|
||||
@@ -230,6 +278,17 @@ jobs:
|
||||
PKG="desktopApp/packaging/flatpak"
|
||||
APP_ID="com.vitorpamplona.amethyst.Desktop"
|
||||
OUT="desktopApp/build/flatpak"
|
||||
# AppImage-style arch names for the bundle filename.
|
||||
case "${{ matrix.arch }}" in
|
||||
x64) BUNDLE_ARCH=x86_64 ; GST_TRIPLET=x86_64-linux-gnu ;;
|
||||
arm64) BUNDLE_ARCH=aarch64 ; GST_TRIPLET=aarch64-linux-gnu ;;
|
||||
*) echo "::error::unsupported arch for Flatpak: ${{ matrix.arch }}"; exit 1 ;;
|
||||
esac
|
||||
# Rewrite the arch-specific GStreamer plugin path in the manifest
|
||||
# (checked-in default is x86_64-linux-gnu). Idempotent — the sed only
|
||||
# matches the original triplet.
|
||||
sed -i "s|/usr/lib/x86_64-linux-gnu/gstreamer-1.0|/usr/lib/${GST_TRIPLET}/gstreamer-1.0|g" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
# Inject the AppStream <release> entry for this build (the checked-in
|
||||
# metainfo deliberately carries none — CI is the source of truth).
|
||||
sed -i "s|<releases>|<releases>\n <release version=\"${VER}\" date=\"$(date -u +%F)\" />|" \
|
||||
@@ -241,7 +300,7 @@ jobs:
|
||||
"${OUT}/build-dir" \
|
||||
"${PKG}/${APP_ID}.yml"
|
||||
flatpak build-bundle "${OUT}/repo" \
|
||||
"${OUT}/Amethyst-${VER}-x86_64.flatpak" \
|
||||
"${OUT}/Amethyst-${VER}-${BUNDLE_ARCH}.flatpak" \
|
||||
"$APP_ID" \
|
||||
--runtime-repo=https://dl.flathub.org/repo/flathub.flatpakrepo
|
||||
ls -la "$OUT"
|
||||
@@ -285,7 +344,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -325,8 +384,17 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "amyImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "amyImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: only amyImage. .deb/.rpm are Linux-only jpackage types
|
||||
# and jpackageMsi for a CLI is deferred (the portable zip is the
|
||||
# documented Windows install path). The launcher script writes both
|
||||
# `bin/amy` (sh) and `bin/amy.bat`, and the assertion below runs
|
||||
# under bash on GH windows runners (git-bash is on PATH). collect_cli_assets
|
||||
# zips the image on Windows instead of tar.gz.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "amyImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "amyImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -337,7 +405,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -536,7 +604,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -574,8 +642,16 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: macos-14, arch: arm64, family: macos, tasks: "geodeImage" }
|
||||
- { os: ubuntu-latest, arch: x64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
- { os: ubuntu-24.04-arm, arch: arm64, family: linux, tasks: "geodeImage jpackageDeb jpackageRpm" }
|
||||
# Windows legs: geodeImage only. The .deb/.rpm are Linux-only; MSI is
|
||||
# deferred (portable zip covers the primary use — operators still
|
||||
# deploy geode via the Docker image or the tarball on Linux). The
|
||||
# image writes both `bin/geode` (sh) and `bin/geode.bat`, and the
|
||||
# smoke test below runs under bash on the windows runner.
|
||||
- { os: windows-latest, arch: x64, family: windows, tasks: "geodeImage" }
|
||||
- { os: windows-11-arm, arch: arm64, family: windows, tasks: "geodeImage" }
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45 # macOS leg also codesigns + notarizes the jlink image
|
||||
defaults:
|
||||
@@ -586,7 +662,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -630,12 +706,23 @@ jobs:
|
||||
# module list is complete for the real relay path (Ktor CIO + SQLite +
|
||||
# NIP-11 serialization) — a too-tight module list links fine but fails
|
||||
# here with NoClassDefFound instead of on an operator's machine.
|
||||
#
|
||||
# On the Windows legs we invoke bin/geode.bat instead of bin/geode. The
|
||||
# tmp path also differs between git-bash on Windows (which resolves /tmp
|
||||
# to a mingw path that curl -o accepts) and POSIX runners; kept identical
|
||||
# because the workflow's `defaults.run.shell: bash` uses git-bash on
|
||||
# Windows and /tmp is a valid mingw path there.
|
||||
- name: Smoke-test the geode image
|
||||
run: |
|
||||
set -euo pipefail
|
||||
IMG="geode/build/geode-image/geode"
|
||||
"$IMG/bin/geode" --version
|
||||
"$IMG/bin/geode" --port 17447 &
|
||||
if [[ "${{ matrix.family }}" == "windows" ]]; then
|
||||
LAUNCHER="$IMG/bin/geode.bat"
|
||||
else
|
||||
LAUNCHER="$IMG/bin/geode"
|
||||
fi
|
||||
"$LAUNCHER" --version
|
||||
"$LAUNCHER" --port 17447 &
|
||||
PID=$!
|
||||
ok=0
|
||||
for i in $(seq 1 20); do
|
||||
@@ -777,7 +864,7 @@ jobs:
|
||||
|
||||
- name: Upload to GH Release (skip on dry-run)
|
||||
if: github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true'
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ steps.ver.outputs.tag }}
|
||||
@@ -831,17 +918,17 @@ jobs:
|
||||
echo "image=$IMAGE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: geode/Dockerfile
|
||||
@@ -866,7 +953,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -1010,7 +1097,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload Android assets to GH Release
|
||||
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1
|
||||
uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
|
||||
with:
|
||||
files: dist/*
|
||||
tag_name: ${{ github.ref_name }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -49,16 +49,24 @@ jobs:
|
||||
# package, installs it, and verifies the process stays alive for 10s.
|
||||
# Catches ProGuard stripping (JNI, reflection), missing jlink modules
|
||||
# (java.management, java.prefs), and native lib bundling issues.
|
||||
#
|
||||
# Runs on both x64 and arm64 hosted runners so release-time arm64 breakage
|
||||
# (e.g. ProGuard rules missing an arch-specific reflection root) is caught
|
||||
# at PR time instead of on the tag build.
|
||||
# -------------------------------------------------------------------------
|
||||
release-deb-launch:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v5
|
||||
uses: actions/setup-java@v5.6.0
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: 21
|
||||
@@ -84,13 +92,29 @@ jobs:
|
||||
chmod +x scripts/relax-deb-libicu.sh
|
||||
scripts/relax-deb-libicu.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
# Mirrors the same step in create-release.yml so this job exercises the
|
||||
# exact .deb release ships. libskiko-linux-arm64.so has libEGL.so.1 in
|
||||
# DT_NEEDED and jpackage does not scan lib/app/ for Depends, so without
|
||||
# this the arm64 app dies at startup with
|
||||
# UnsatisfiedLinkError: libEGL.so.1: cannot open shared object file
|
||||
# See scripts/add-deb-libegl-dep.sh for the full rationale.
|
||||
- name: Add libegl1 dep to arm64 .deb
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x scripts/add-deb-libegl-dep.sh
|
||||
scripts/add-deb-libegl-dep.sh desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
- name: Install .deb
|
||||
run: |
|
||||
# Installed via apt (not `dpkg -i`) so the .deb's declared Depends are
|
||||
# actually resolved — that is what pulls in libegl1 on the arm64
|
||||
# runner, which does not ship it preinstalled.
|
||||
#
|
||||
# jpackage's post-install script runs xdg-desktop-menu which fails
|
||||
# on CI runners ("No writable system menu directory"). The files are
|
||||
# extracted successfully; only the menu registration fails. Allow the
|
||||
# dpkg error, then verify the binary was actually installed.
|
||||
sudo dpkg -i desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
# install error, then verify the binary was actually installed.
|
||||
sudo apt-get install -y ./desktopApp/build/compose/binaries/main-release/deb/*.deb || true
|
||||
echo "Installed files:"
|
||||
dpkg -L amethyst | head -30
|
||||
# Fail if the binary wasn't actually extracted
|
||||
@@ -139,5 +163,6 @@ jobs:
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Release DEB (smoke-tested)
|
||||
# Artifact names must be unique across a run — disambiguate per arch.
|
||||
name: Release DEB (smoke-tested, ${{ matrix.os }})
|
||||
path: desktopApp/build/compose/binaries/main-release/deb/*.deb
|
||||
|
||||
+33
-12
@@ -35,7 +35,12 @@ All platforms:
|
||||
Platform-specific:
|
||||
|
||||
- **macOS**: Xcode Command Line Tools (`xcode-select --install`)
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`
|
||||
- **Windows**: WiX Toolset 3.x on PATH (for MSI). `winget install WiXToolset.WiXToolset`.
|
||||
Windows arm64 builds run on the free public-repo `windows-11-arm` GitHub runner
|
||||
and produce the portable `.zip` only — that image ships no WiX, so CI cannot
|
||||
package an arm64 MSI. Locally you *can* build one on an arm64 Windows box with
|
||||
WiX 3.x installed (jpackage produces host-native artifacts; the WiX 3 binaries
|
||||
themselves are x86 and run under emulation).
|
||||
- **Linux (all)**: nothing extra for `.deb`; `rpm` + `fakeroot` for `.rpm`;
|
||||
`appimagetool` + `desktop-file-utils` for AppImage; `flatpak` +
|
||||
`flatpak-builder` for the Flatpak bundle (see
|
||||
@@ -57,9 +62,12 @@ Install appimagetool locally (CI fetches its own — SHA-verified):
|
||||
# Debian/Ubuntu — appimagetool calls desktop-file-validate on the .desktop entry
|
||||
sudo apt-get install -y desktop-file-utils
|
||||
|
||||
curl -fsSL -o desktopApp/packaging/appimage/appimagetool-x86_64.AppImage \
|
||||
https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage
|
||||
chmod +x desktopApp/packaging/appimage/appimagetool-x86_64.AppImage
|
||||
# createReleaseAppImage picks appimagetool-<arch>.AppImage matching the JVM's
|
||||
# os.arch — fetch the one for your host (x86_64 on Intel/AMD, aarch64 on ARM).
|
||||
ARCH="$(uname -m)"
|
||||
curl -fsSL -o "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage" \
|
||||
"https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-${ARCH}.AppImage"
|
||||
chmod +x "desktopApp/packaging/appimage/appimagetool-${ARCH}.AppImage"
|
||||
```
|
||||
|
||||
---
|
||||
@@ -110,8 +118,8 @@ are **not** required to build Amethyst from the committed sources.
|
||||
| Windows MSI | `./gradlew :desktopApp:packageReleaseMsi` | `desktopApp/build/compose/binaries/main-release/msi/Amethyst-*.msi` |
|
||||
| Linux `.deb` | `./gradlew :desktopApp:packageReleaseDeb` | `desktopApp/build/compose/binaries/main-release/deb/amethyst_*.deb` |
|
||||
| Linux `.rpm` | `./gradlew :desktopApp:packageReleaseRpm` | `desktopApp/build/compose/binaries/main-release/rpm/amethyst-*.rpm` |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-x86_64.AppImage` |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-x86_64.flatpak` (CI) |
|
||||
| Linux AppImage | `./gradlew :desktopApp:createReleaseAppImage` | `desktopApp/build/appimage/Amethyst-*-<arch>.AppImage` (x86_64 or aarch64, from host) |
|
||||
| Linux Flatpak | `flatpak-builder` over `createReleaseDistributable` output — see [`desktopApp/packaging/flatpak/README.md`](desktopApp/packaging/flatpak/README.md) | `desktopApp/build/flatpak/Amethyst-*-<arch>.flatpak` (CI; x86_64 or aarch64) |
|
||||
| Windows `.zip` portable | See below (inline `7z`) | — |
|
||||
| Linux `.tar.gz` portable | See below (inline `tar`) | — |
|
||||
|
||||
@@ -325,14 +333,27 @@ Quartz library in one pipeline.
|
||||
|
||||
3. **Wait** for the `Create Release Assets` workflow to finish (~25–30 min).
|
||||
|
||||
4. **Verify** — the GH Release should hold **31 assets**:
|
||||
- **8 desktop** — `dmg` (macOS arm64), `msi` + `zip` (Windows), `deb`, `rpm`,
|
||||
`AppImage`, `flatpak`, `tar.gz` (Linux). There is **no Intel/x64 macOS
|
||||
DMG** — `jpackage` cannot cross-compile and no Intel runner leg is
|
||||
configured, so macOS ships arm64-only.
|
||||
4. **Verify** — the GH Release should hold **47 assets**:
|
||||
- **14 desktop**, one per matrix leg × format:
|
||||
- macOS arm64: `dmg` (1)
|
||||
- Windows x64: `msi` + portable `zip` (2)
|
||||
- Windows arm64: portable `zip` only (1) — **no arm64 MSI**, see below
|
||||
- Linux x64 / arm64: `deb` + `rpm` (4)
|
||||
- Linux-portable x64 / arm64: `AppImage` + `tar.gz` + `flatpak` (6)
|
||||
|
||||
There is **no Intel/x64 macOS DMG** — `jpackage` cannot cross-compile
|
||||
and no Intel runner leg is configured, so macOS ships arm64-only.
|
||||
There is **no Windows arm64 MSI**: `jpackage --type msi` shells out to
|
||||
WiX 3's `heat`/`candle`/`light`, and the `windows-11-arm` runner image
|
||||
ships no WiX (`windows-latest` has WiX 3.14 preinstalled, which is why
|
||||
the x64 leg gets an MSI). Revisit if that image gains WiX, or if
|
||||
jpackage learns the WiX 4+ `wix build` CLI.
|
||||
- **13 Android** — 5 Google Play APKs + 5 F-Droid APKs + 2 AABs + the
|
||||
F-Droid `.apks` set built for Accrescent.
|
||||
- **5 amy** + **5 geode** bundles.
|
||||
- **10 amy** — `tar.gz` (macOS arm64, Linux x64, Linux arm64),
|
||||
`deb` + `rpm` per Linux arch, portable `zip` per Windows arch, and the
|
||||
one arch-independent no-JRE `amy-<ver>-jvm.tar.gz` for Homebrew-core.
|
||||
- **10 geode** — same shape as amy.
|
||||
- Asset sizes look sane (see §Enforce asset size budget — CI auto-fails at 1 GB/asset)
|
||||
- Android flow unchanged
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,808 @@
|
||||
# Location: foreground-gate the listener, trim the request, fix the meter
|
||||
|
||||
Date: 2026-07-29
|
||||
Module: `amethyst`
|
||||
Origin: Finding 1 of `2026-07-29-resource-report-1.13.0-analysis.md` (1.13.0-PLAY,
|
||||
Pixel 9a / Android 17)
|
||||
Revision: 2 — incorporates spec review of 2026-07-29
|
||||
|
||||
## Context
|
||||
|
||||
The 1.13.0 resource report showed **7.13 h of location listening against 9.1
|
||||
seconds of app foreground**, and the accompanying analysis called it the leading
|
||||
suspect for that day's 11 pp of background battery drain. Root cause given: 30
|
||||
`SharingStarted.Eagerly` top-nav filter states on the account scope
|
||||
(`Account.kt:843-933`), one of which is always `TopFilter.AroundMe` because
|
||||
`AccountSettings.kt:256` ships that as the Products default. The `AroundMe`
|
||||
branch collects `locationFlow()`, and an `Eagerly`-shared subscriber holds
|
||||
`LocationState`'s `WhileSubscribed(5000)` open for the life of the process.
|
||||
|
||||
That chain is real. **The battery conclusion drawn from it is not**, and this
|
||||
spec is written against the measurement rather than the inference.
|
||||
|
||||
### What the device reports
|
||||
|
||||
`amethyst/src/main/AndroidManifest.xml:80` declares **only**
|
||||
`ACCESS_COARSE_LOCATION` — no `ACCESS_FINE_LOCATION`, no
|
||||
`ACCESS_BACKGROUND_LOCATION` — and no service declares
|
||||
`foregroundServiceType="location"` (the declared types are `mediaPlayback`,
|
||||
`microphone`, `camera`, `phoneCall`, `shortService`, `dataSync`, `specialUse`).
|
||||
`targetSdk = 37`.
|
||||
|
||||
`adb shell dumpsys location`, Pixel 9a, 21 d 7 h of uptime. **These are the
|
||||
`com.vitorpamplona.amethyst` rows** of the per-provider *Historical Aggregate
|
||||
Location Provider Data* block — not system-wide totals:
|
||||
|
||||
| provider | registration held | **active** | **foreground** | fixes |
|
||||
|---|---|---|---|---|
|
||||
| passive | 9 d 14 h 20 m | 8 h 26 m 14 s | 8 h 14 m 50 s | 107 |
|
||||
| network | 9 d 14 h 20 m | 8 h 26 m 13 s | 8 h 14 m 49 s | 95 |
|
||||
| fused | 9 d 14 h 20 m | 8 h 26 m 12 s | 8 h 14 m 48 s | 95 |
|
||||
| gps | 9 d 14 h 20 m | 8 h 26 m 11 s | 8 h 14 m 47 s | 98 |
|
||||
|
||||
Roughly **11 minutes of background-active location in three weeks**, and about
|
||||
100 delivered fixes per provider. With the app backgrounded at the time of the
|
||||
dump: `gps provider: service: ProviderRequest[OFF]`, `gps_hardware:
|
||||
mStarted=false`.
|
||||
|
||||
The cleanest assumption-free comparison: the ledger's **two-day** `location.ms`
|
||||
total (3.57 h + 7.13 h = 10.70 h) **exceeds the OS's three-week active total**
|
||||
(8 h 26 m) by 27 %. `location.ms` is not measuring what its label implies.
|
||||
|
||||
**One figure does not reconcile, and is left open.** Registration-held is
|
||||
9 d 14 h over 21 d 7 h ≈ 10.8 h/day, whereas `location.ms` averages 5.35 h/day
|
||||
across the two ledger days. If `location.ms` measured subscription existence
|
||||
these should agree; they are ~2× apart. Candidates: segments open at process
|
||||
death are lost (the pre-flush hook does not run on a kill, and the report shows
|
||||
6 process starts across the two days); the ledger covers 2 days while dumpsys
|
||||
spans 21 with different usage. Not investigated. It does not affect the
|
||||
conclusion below, which rests on `location.ms` versus OS-*active* time, not
|
||||
versus registration-held.
|
||||
|
||||
### How far the "no background location" claim generalises
|
||||
|
||||
This matters because the "no behaviour change" argument rests on it, so it is
|
||||
scoped rather than asserted universally:
|
||||
|
||||
- **API 29+ (Android 10 and up):** `ACCESS_BACKGROUND_LOCATION` gates background
|
||||
access, and for `targetSdk ≥ 29` an FGS additionally needs
|
||||
`foregroundServiceType="location"`. Amethyst has neither, so background
|
||||
registrations are suspended. This is the case the Pixel 9a measurement above
|
||||
covers.
|
||||
- **API 26–28 (Android 8–9):** `ACCESS_BACKGROUND_LOCATION` does not exist.
|
||||
Amethyst *can* receive background location there, throttled by the platform to
|
||||
a few updates per hour. The gate is a genuine, if small, improvement on these
|
||||
releases rather than a no-op.
|
||||
|
||||
So "structural" applies to API 29+; on 26–28 the change has real effect.
|
||||
|
||||
### What is actually wrong
|
||||
|
||||
1. **The meter lies.** `location.ms` measures how long a *subscription* existed,
|
||||
not how long anything listened. That is what made Finding 1 read as the
|
||||
top-priority battery bug.
|
||||
2. **The request is 4× redundant.** `LocationFlow.kt:55` iterates
|
||||
`locationManager.allProviders` and calls `requestLocationUpdates` on each —
|
||||
passive, network, fused **and** gps (the last tagged `HIGH_ACCURACY`) — every
|
||||
one at `@+10s0ms, minUpdateDistance=100.0`, to produce a **5 km** geohash.
|
||||
This burns during the 8 h 14 m the app genuinely is foreground.
|
||||
3. **The subscription is held for 45 % of device uptime** doing nothing, because
|
||||
`Eagerly` never lets go.
|
||||
4. **Every user is exposed**, since Products defaults to `AroundMe` and needs no
|
||||
opt-in.
|
||||
5. **Location may be entirely broken on Android 8–11** — see Hypothesis H1.
|
||||
|
||||
## Hypothesis H1 — location is dead below API 31 (unverified)
|
||||
|
||||
Through Android 11, AOSP's `getMinimumPermissionForProvider` required
|
||||
`ACCESS_FINE_LOCATION` for the `gps`, `passive` and `fused` providers; only
|
||||
`network` accepted `ACCESS_COARSE_LOCATION`. Approximate-location, which lets a
|
||||
coarse-only app request any provider and receive a fuzzed result, is an Android
|
||||
12 (API 31) change.
|
||||
|
||||
Amethyst holds coarse only. So on API 26–30 today's `allProviders` loop should
|
||||
throw `SecurityException` on three of the four providers. Two consequences:
|
||||
|
||||
- The throw escapes the `callbackFlow` builder → `.catch` in `LocationState` →
|
||||
`LackPermission`. Location would be **non-functional** on Android 8–11.
|
||||
- The builder aborting means `awaitClose` never runs, so `removeUpdates` is
|
||||
never called and any registration made before the throw **leaks** for the life
|
||||
of the process.
|
||||
|
||||
**The leak is iteration-order dependent, and may not occur at all.**
|
||||
`getLastKnownLocation` is permission-checked per provider too, and
|
||||
`LocationFlow.kt:56-68` calls it *before* `requestLocationUpdates` on each
|
||||
iteration. If a fine-only provider comes first in `allProviders` — `passive`
|
||||
does, in the common AOSP ordering — the throw lands before any registration
|
||||
exists: dead, but not leaking. A leak requires `network` to precede a fine-only
|
||||
provider.
|
||||
|
||||
`@SuppressLint("MissingPermission")` at `LocationFlow.kt:40` suppresses the lint
|
||||
warning, not the runtime check, so this would not have been caught statically.
|
||||
|
||||
**Not reproduced.** Verify before implementing, on the existing
|
||||
`Medium_Phone_API_26_8_` AVD: grant coarse only, open a screen that subscribes,
|
||||
and capture **both** the `SecurityException` *and* the actual
|
||||
`locationManager.allProviders` order (log it). The PR should claim only what that
|
||||
run observed — "location is dead on Android 8–11" and "registrations leak" are
|
||||
separate claims and the second may not hold.
|
||||
|
||||
The design below is written to be correct either way (§B excludes
|
||||
permission-incompatible providers by API level, and catches `SecurityException`
|
||||
per provider). If H1 holds, this change also **fixes location on Android 8–11**,
|
||||
which should be called out in the PR.
|
||||
|
||||
### H1 verification result (2026-07-30, Pixel 9a, API 37)
|
||||
|
||||
Partial. The API-level claim could **not** be tested on this hardware: API 31+
|
||||
grants coarse-only apps access to every provider, so no `SecurityException` can
|
||||
appear regardless of whether H1 is true. Only an API ≤ 30 image can settle it.
|
||||
|
||||
What *was* settled is the ordering, which decides the leak sub-claim.
|
||||
`dumpsys location` recent-events shows the same iteration order on every
|
||||
registration cycle across two days, all four sharing one registration id
|
||||
(`88A8E679`), confirming a single `LocationFlow` subscription:
|
||||
|
||||
```
|
||||
07-30 07:09:58.282: passive provider +registration .../88A8E679
|
||||
07-30 07:09:58.291: network provider +registration .../88A8E679
|
||||
07-30 07:09:58.293: fused provider +registration .../88A8E679
|
||||
07-30 07:09:58.299: gps provider +registration .../88A8E679
|
||||
```
|
||||
|
||||
`allProviders` yields **passive first**, and `passive` is one of the fine-only
|
||||
providers below API 31. So on Android 8–11 the throw would land on the first
|
||||
iteration, before any registration exists:
|
||||
|
||||
- "location is dead on Android 8–11" — **still unverified**, needs API ≤ 30.
|
||||
- "registrations leak" — **disproved for this ordering**. Dead, but not leaking.
|
||||
|
||||
The PR must not claim the leak. Caveat: the ordering is observed on API 37 and
|
||||
`getAllProviders()` could order differently on API 26.
|
||||
|
||||
**Unrelated but decisive "before" datum, same session:** with
|
||||
`mWakefulness=Dozing` (screen off, device dozing) and `MainActivity` sitting in
|
||||
`mLastPausedActivity`, Amethyst held **four** live registrations at
|
||||
`@+10s0ms / minUpdateDistance=100.0`. That is the state §A's gate exists to
|
||||
eliminate, captured on the owner's daily-driver device rather than an emulator.
|
||||
|
||||
## Goals
|
||||
|
||||
- Release the location registration whenever no activity is started.
|
||||
- Register on one appropriate, permission-compatible provider at an interval
|
||||
matched to the precision actually needed.
|
||||
- Make `location.ms` reflect real listening time, correctly, under concurrency.
|
||||
- No user-visible behaviour change to the "Around Me" feed or geohash chats.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the Products `AroundMe` default (`AccountSettings.kt:256`). With the
|
||||
gate in place its cost is bounded to foreground use. Worth revisiting
|
||||
separately as a product decision.
|
||||
- Requesting `ACCESS_FINE_LOCATION` or `ACCESS_BACKGROUND_LOCATION`.
|
||||
- Findings 2–6 of the source analysis. Finding 2 (the relay reconnect storm,
|
||||
1.65 GB/day at a 75 % dial-failure rate) is the more likely explanation for
|
||||
the background battery drain and should be taken next.
|
||||
|
||||
## Design
|
||||
|
||||
### A. The gate
|
||||
|
||||
`LocationState` gains an `isForeground: StateFlow<Boolean>` parameter, wired in
|
||||
`AppModules.kt:251` from the existing `foregroundTracker` (`AppModules.kt:333`,
|
||||
registered at `Amethyst.kt:122`). `locationManager` is `by lazy`, so
|
||||
initialisation order is safe.
|
||||
|
||||
Today's `hasLocationPermission.transformLatest { … }` becomes a three-state gate
|
||||
over *permission × foreground*, applied identically to `geohashStateFlow` and
|
||||
`preciseGeohashStateFlow`:
|
||||
|
||||
| gate state | behaviour |
|
||||
|---|---|
|
||||
| no permission | emit `LackPermission` (unchanged) |
|
||||
| permitted, foreground | **R1**: emit `Loading` *only if* no `Success` is cached; then `emitAll(locationSource(…))` |
|
||||
| permitted, backgrounded | emit nothing; the registration is released and the `StateFlow` retains its last value |
|
||||
|
||||
**R1 is a requirement, not an improvement.** `AroundMeFeedFlow.convert` collapses
|
||||
to `geotags = emptySet()` for anything that is not `Success`. Without R1 the gate
|
||||
would make the "Around Me" feed flash empty on **every** return to foreground — a
|
||||
new, frequent, user-visible regression introduced by this change. (It also fixes
|
||||
the same flash on permission grant, which exists today.)
|
||||
|
||||
**R1 corollary: the `NoPermission` branch must not clear the cache.** Today's
|
||||
code emits `LackPermission` without touching `latestLocation`
|
||||
(`LocationState.kt:94-96`), and that stays. Clearing it is superficially
|
||||
attractive — a revoked permission arguably should not leave a fix readable — but
|
||||
consumers already see `LackPermission` from the `StateFlow`; `latestLocation` is
|
||||
private and its only jobs are seeding `stateIn` and deciding whether `Loading` is
|
||||
emitted. Clearing it would therefore buy no privacy and would cost an
|
||||
empty-feed flash on every permission flap, which is precisely what R1 exists to
|
||||
prevent. The cache is in-memory and dies with the process regardless.
|
||||
|
||||
The `.catch` branch **does** clear the cache, and keeps doing so. That asymmetry
|
||||
looks arbitrary next to the paragraph above, so to be explicit: it is inherited,
|
||||
not introduced. Both branches preserve today's behaviour exactly
|
||||
(`LocationState.kt:87-91` clears on failure, `:94-96` does not clear on missing
|
||||
permission). This corollary argues against *adding* a clear, not for removing
|
||||
the existing one — changing it would be an unmotivated behaviour change. The
|
||||
asymmetry is also defensible on its own terms: a source that failed mid-stream
|
||||
says something about the fix's provenance, whereas a permission known to be
|
||||
absent says nothing about a fix already taken.
|
||||
|
||||
**R2 — grace period on the background edge.** The gate must delay the
|
||||
`foreground → background` transition by **5 s** before tearing down. Without it a
|
||||
one-second app switch destroys and rebuilds the registration, including a full
|
||||
`getLastKnownLocation` sweep, so a user flipping between apps pays more than the
|
||||
steady state. 5 s matches the existing `WhileSubscribed(5000)` and is the same
|
||||
intent. The `background → foreground` edge is **not** delayed.
|
||||
|
||||
Mechanism, stated because the obvious operator is the wrong one: `debounce(5000)`
|
||||
delays both edges, and the duration-selector overload that would allow an
|
||||
asymmetric delay is `@FlowPreview`. Use `transformLatest`, already in this file
|
||||
and already opted into via `@OptIn(ExperimentalCoroutinesApi::class)`:
|
||||
|
||||
```kotlin
|
||||
isForeground.transformLatest { fg ->
|
||||
if (!fg) delay(BACKGROUND_GRACE_MS)
|
||||
emit(fg)
|
||||
}
|
||||
```
|
||||
|
||||
`transformLatest` cancels the pending `delay` if foreground returns first, which
|
||||
is exactly the stated semantics, with no preview opt-in.
|
||||
|
||||
**R3 — the retained-value contract.** The "emit nothing" branch is what keeps
|
||||
this behaviour-neutral: `stateIn` holds the last `Success`, so the ~60
|
||||
synchronous `.value` reads across the feed filters
|
||||
(`HomeNewThreadFeedFilter.kt`, `VideoFeedFilter.kt`,
|
||||
`DiscoverLongFormFeedFilter.kt`, …) keep seeing the last known geohash. A 5 km
|
||||
cell does not meaningfully decay while backgrounded.
|
||||
|
||||
**R4 — memory visibility.** `latestLocation` and `latestPreciseLocation`
|
||||
(`LocationState.kt:63-64`) are plain `var`s today, used only as `stateIn` initial
|
||||
values. R1 promotes them to control flow, read from a different coroutine than
|
||||
the `onEach` that writes them. They must become `@Volatile` (or
|
||||
`MutableStateFlow`).
|
||||
|
||||
**Rejected alternative:** switching `FeedTopNavFilterState.flow` from `Eagerly`
|
||||
to `WhileSubscribed`. Roughly 60 call sites read
|
||||
`account.live*FollowLists.value` synchronously rather than collecting; under
|
||||
`WhileSubscribed` those reads would silently serve a stale or initial value
|
||||
whenever no collector happened to be active. That is a correctness regression,
|
||||
not a battery fix.
|
||||
|
||||
**Rejected alternative:** gating only at the `AppModules` wiring point
|
||||
(`geolocationFlow = { … }`). Smaller diff, but it leaves the raw
|
||||
`geohashStateFlow` as a loaded gun for the next eager consumer, does nothing for
|
||||
`preciseGeohashStateFlow`, and introduces a second `StateFlow` layer over the
|
||||
same data.
|
||||
|
||||
### B. Request shape
|
||||
|
||||
`LocationFlow.get` registers on **one** provider, chosen by a ladder over
|
||||
**provider existence and permission compatibility** — both static facts:
|
||||
|
||||
```
|
||||
chooseProviders(sdkInt, hasFine, exists) -> List<String>:
|
||||
API 31+ or hasFine → [FUSED, NETWORK, GPS, PASSIVE] filtered by exists
|
||||
API < 31, coarse → [NETWORK] filtered by exists (see H1)
|
||||
```
|
||||
|
||||
It returns the **ordered candidate list**, not a single choice, because the
|
||||
per-provider `SecurityException` fall-through below needs somewhere to fall to.
|
||||
An empty list means no compatible provider exists.
|
||||
|
||||
**The ladder deliberately does not consult `isProviderEnabled`.** Today's code
|
||||
registers regardless of enabled state, and such a registration goes live by
|
||||
itself when the user enables location — including from the quick-settings shade
|
||||
without leaving the app, which is exactly what someone does after seeing "Around
|
||||
Me" empty. A guard evaluated once at subscription start would lose that, and the
|
||||
foreground-transition restart does not cover the in-app path. Selecting on
|
||||
existence keeps the property with no `PROVIDERS_CHANGED_ACTION` receiver. If
|
||||
field reports show dead feeds on devices where the chosen provider exists but is
|
||||
disabled while another is enabled, adding that receiver is the follow-up.
|
||||
|
||||
`requestLocationUpdates` is wrapped in a per-provider `SecurityException` catch
|
||||
that falls through to the next rung, so H1 cannot abort the builder and leak
|
||||
registrations regardless of how the AOSP check actually behaves.
|
||||
|
||||
**When no provider can be registered** — the candidate list was empty, or every
|
||||
rung threw — `LocationFlow` **throws** `SecurityException`. It cannot emit
|
||||
`LackPermission`: the seam is `(Long, Float) -> Flow<Location>`, and
|
||||
`LackPermission` is a `LocationState.LocationResult`, which `LocationFlow` has no
|
||||
way to express. Throwing routes it through the `.catch` already present in
|
||||
`LocationState` (`LocationState.kt:87-91`, `:126-130`), which sets
|
||||
`latestLocation = LackPermission` and emits it — the existing, unchanged path.
|
||||
|
||||
Throwing covers **both** failure cases, and it subsumes R5's `registered`-flag
|
||||
guard: the throw happens before the acquire, so `onListening(true)` cannot fire
|
||||
without a live registration and no separate flag is needed. That is only half of
|
||||
R5's pairing, though — see R5 for the release half, which the throw does **not**
|
||||
cover and which needs `try`/`finally`.
|
||||
|
||||
**Stated decision: `LackPermission` stays conflated with "no usable provider".**
|
||||
That value renders `R.string.lack_location_permissions` — "No Location
|
||||
Permissions" — at `DisplayLocationObserver.kt:49` and `FeedFilterSpinner.kt:224`,
|
||||
which is wrong for a coarse-only pre-31 device that has no `network` provider.
|
||||
The conflation is accepted rather than introduced: if H1 holds, today's
|
||||
`SecurityException` already lands in the same `.catch` and shows the same wrong
|
||||
message. Adding an `Unavailable` state would ripple through four UI `when`s plus
|
||||
`LocationState` (10 references across 5 files) and belongs with the H1 fix
|
||||
messaging, not here. Recorded as a follow-up.
|
||||
|
||||
The `getLastKnownLocation` seed stays a sweep across all providers, taking the
|
||||
freshest result. It requires no registration and is what makes the first geohash
|
||||
appear immediately rather than after a fix.
|
||||
|
||||
`MIN_TIME` / `MIN_DISTANCE` split into two profiles, passed per call:
|
||||
|
||||
| flow | precision | interval / distance | provider set |
|
||||
|---|---|---|---|
|
||||
| `geohashStateFlow` | `KM_5_X_5` | 10 s / 100 m → **60 s / 500 m** | 4 → 1 |
|
||||
| `preciseGeohashStateFlow` | `BUILDING` (8 chars) | 10 s / 100 m (kept) | 4 → 1 |
|
||||
|
||||
Both rows change: the ladder narrows the precise flow's provider set too, and
|
||||
below API 31 that means `network` only, no GPS. Academic while the app holds
|
||||
coarse only (see Follow-ups), but it is not "unchanged".
|
||||
|
||||
At 120 km/h a 5 km cell takes 2.5 minutes to cross, so 60 s / 500 m has no
|
||||
observable effect on the feed.
|
||||
|
||||
**Rejected alternative — one shared source at the fine profile,** deriving the
|
||||
coarse geohash by prefix truncation. It halves registrations and removes the need
|
||||
for `RefCountedSession` entirely, but it upgrades the **common** case — the
|
||||
"Around Me" feed alone, which is always on via the Products default — from
|
||||
60 s/500 m to 10 s/100 m. That trades the change's main win for a rarer one.
|
||||
|
||||
**Rejected alternative — one shared source whose profile tracks the finest
|
||||
active subscriber.** Recovers the above and is the best of the three on both
|
||||
axes, but it is refcounting with the counter moved from the meter into the
|
||||
request path, for a benefit bounded by how often the two flows overlap. They
|
||||
overlap only while one of three composable-scoped, foreground-only screens is
|
||||
open (`GeohashChatScreen`, `NewGeohashChatScreen`,
|
||||
`GeohashLocationPickerDialog`). Not worth the machinery; revisit if that changes.
|
||||
|
||||
### C. The meter
|
||||
|
||||
`AppModules.kt:251` hands both flows the same non-refcounted
|
||||
`SessionTimeIntegrator`, so `setActive(false)` from either closes the segment
|
||||
while the other is still listening. Both can be live at once — the "Around Me"
|
||||
feed plus an open geohash chat.
|
||||
|
||||
**R5 — the hook moves inside `LocationFlow`, and both edges are paired.** Today
|
||||
`onListening(true)` is an `onStart` on the flow returned by `LocationFlow.get`,
|
||||
so it fires on *collection* whether or not anything was registered — meaning a
|
||||
device with no usable provider accrues `location.ms` with nothing listening,
|
||||
reintroducing the exact defect this section exists to fix. The hook must instead
|
||||
fire from inside the `callbackFlow`, after `requestLocationUpdates` returns
|
||||
without throwing, and again on the way out.
|
||||
|
||||
The obvious "way out" is `awaitClose`, and that would introduce a worse bug than
|
||||
it fixes — twice over. First, `awaitClose` runs on every normal
|
||||
completion, including one where no rung ever registered, so it would fire an
|
||||
**unpaired** `onListening(false)`. With R6 that does not merely under-count — it
|
||||
decrements a holder it never acquired, stealing another flow's. Concretely:
|
||||
`geohashStateFlow` registers (`holders = 1`), `preciseGeohashStateFlow` fails to
|
||||
register and closes (`holders = 0`), and the session latches off while the coarse
|
||||
flow is still listening. `coerceAtLeast(0)` does not help; the count never went
|
||||
negative.
|
||||
|
||||
Second — and this is the one that survives fixing the first — `awaitClose` also
|
||||
fails to run at all on some paths that *did* register. See below.
|
||||
|
||||
The pair therefore has to be guaranteed from **both** ends, and the two ends need
|
||||
different mechanisms.
|
||||
|
||||
*No acquire without a registration* is §B's **throw**: if no rung registers, the
|
||||
builder throws before reaching the acquire at all.
|
||||
|
||||
*No acquire without a release* needs `try`/`finally`, **not** `awaitClose`. This
|
||||
is the subtlest point in the document, so the justification below is the one that
|
||||
was **demonstrated**, not the one that sounds most obvious.
|
||||
|
||||
Anything between the acquire and `awaitClose` that unwinds skips cleanup parked
|
||||
inside `awaitClose`, because `awaitClose` is never reached to register it. The
|
||||
registration then leaks and the refcount sticks at ≥ 1 for the life of the
|
||||
process, so `location.ms` accrues forever with nothing listening — this exact
|
||||
defect, arrived at from the other direction, and unrecoverable once hit.
|
||||
|
||||
The **proven** path is the seed throwing a non-cancellation exception:
|
||||
`getLastKnownLocation` is a binder call and can fail. The regression test
|
||||
`releasesTheRegistrationWhenTheSeedThrows` provokes exactly this and was watched
|
||||
failing against an `awaitClose`-only implementation
|
||||
(`expected:<[true, false]> but was:<[true]>`).
|
||||
|
||||
A cancellation during the seed is *in principle* a second such path, since `send`
|
||||
is a suspending call. Recorded honestly: **this one could not be reproduced.**
|
||||
Two attempts during implementation both produced tests that passed against a
|
||||
deliberately broken implementation, because `callbackFlow`'s channel is buffered,
|
||||
so `send` returns without suspending and never observes the cancel. Do not treat
|
||||
the cancellation story as the reason for the `try`/`finally`; a future reader who
|
||||
tries to reproduce it, fails, and concludes the guard is unnecessary would
|
||||
reintroduce the leak.
|
||||
|
||||
```kotlin
|
||||
var registered: String? = null
|
||||
for (provider in candidates) {
|
||||
try {
|
||||
locationManager.requestLocationUpdates(provider, minTimeMs, minDistanceM, callback, Looper.getMainLooper())
|
||||
registered = provider
|
||||
break
|
||||
} catch (e: SecurityException) { /* next rung */ }
|
||||
}
|
||||
if (registered == null) throw SecurityException("no usable location provider")
|
||||
|
||||
onListening?.invoke(true) // cannot fire without a registration
|
||||
try {
|
||||
freshestLastKnownLocation(providers)?.let { send(it) } // suspends — cancellable
|
||||
awaitClose { } // only to satisfy callbackFlow's contract
|
||||
} finally {
|
||||
locationManager.removeUpdates(callback)
|
||||
onListening?.invoke(false) // cannot be skipped
|
||||
}
|
||||
```
|
||||
|
||||
`onListening?.invoke(true)` sits immediately before the `try`, with no suspension
|
||||
between them, so the acquire cannot happen outside the block that guarantees its
|
||||
release.
|
||||
|
||||
`trySend` for the seed would also close this particular hole, being
|
||||
non-suspending. It is rejected because it leaves the invariant resting on nobody
|
||||
adding a suspending call to that block later — vigilance rather than
|
||||
impossibility, which is the standard the rest of R5 is held to.
|
||||
|
||||
**R6 — refcounting.** An `AtomicInteger` beside the `setActive` call is not
|
||||
sufficient: two threads can leave the counter at 1 while the last
|
||||
`setActive(false)` lands after the `setActive(true)`, latching the session off.
|
||||
The count and the transition must move under one lock. New class in
|
||||
`service/resourceusage/`:
|
||||
|
||||
```kotlin
|
||||
class RefCountedSession(private val setSessionActive: (Boolean) -> Unit) {
|
||||
private val lock = Any()
|
||||
private var holders = 0
|
||||
|
||||
fun setActive(active: Boolean) =
|
||||
synchronized(lock) {
|
||||
holders = if (active) holders + 1 else (holders - 1).coerceAtLeast(0)
|
||||
setSessionActive(holders > 0)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It takes the setter as a lambda rather than a `SessionTimeIntegrator` because
|
||||
that is all it needs, and because constructing a real integrator in a unit test
|
||||
would drag in a `ResourceUsageAccountant`, a `ResourceUsageStore` and a temp
|
||||
file to observe one boolean.
|
||||
|
||||
`AppModules` wires `RefCountedSession(locationSession::setActive)` and passes
|
||||
`onListening = { locationRefCount.setActive(it) }`. The outer
|
||||
lock serialises entry into `SessionTimeIntegrator.setActive`, whose own lock is
|
||||
then nested but never acquired in the reverse order, so there is no deadlock.
|
||||
`coerceAtLeast(0)` guards an unmatched release.
|
||||
|
||||
### What `location.ms` means after this change
|
||||
|
||||
Stated plainly, because the finding that opened this spec is "the meter lies"
|
||||
and the next reader should not over-trust the fixed number the way the last one
|
||||
over-trusted the broken one:
|
||||
|
||||
> `location.ms` measures **how long a location registration was held while the
|
||||
> app was in the foreground**. It is not radio-on time and not an energy
|
||||
> figure. A `network`-provider registration at 60 s costs close to nothing; a
|
||||
> `gps` registration at 10 s costs a great deal. The counter cannot tell them
|
||||
> apart.
|
||||
|
||||
Reading it as a battery signal requires knowing which provider was chosen —
|
||||
which the ledger does not record. Recording the chosen provider as a separate
|
||||
counter is a possible follow-up.
|
||||
|
||||
## Testing
|
||||
|
||||
JVM unit tests — JUnit + MockK + `kotlinx-coroutines-test`, no Robolectric,
|
||||
alongside the existing `service/resourceusage/ResourceUsageLedgerTest.kt`.
|
||||
|
||||
**Gate.** `LocationState` gains `locationSource: (Long, Float) -> Flow<Location>`,
|
||||
defaulting to `LocationFlow(context.getSystemService(…) as LocationManager)::get`
|
||||
(see *Registration pairing* for why `LocationFlow` now takes the manager rather
|
||||
than the `Context`). That is the seam:
|
||||
`Location.toGeoHash` is `GeoHash.encode(lat, lon, chars)` from quartz — pure
|
||||
Kotlin — and `unitTests.isReturnDefaultValues = true` is already set, so a
|
||||
`mockk<Location>` with stubbed `latitude`/`longitude` suffices. Against a
|
||||
counting fake source:
|
||||
|
||||
- backgrounded + permitted → source never subscribed
|
||||
- foreground + permitted → subscribed exactly once
|
||||
- foreground → background → subscription released after the R2 grace period,
|
||||
last `Success` still readable via `.value`
|
||||
- background edge shorter than the grace period → subscription **not** torn down
|
||||
- return to foreground with a cached `Success` → **no** `Loading` emission (R1)
|
||||
- return to foreground with no cached fix → `Loading` first
|
||||
- permission revoked → `LackPermission` regardless of foreground state
|
||||
|
||||
**Provider ladder.** Extracted as a pure function
|
||||
`chooseProviders(sdkInt: Int, hasFine: Boolean, exists: (String) -> Boolean):
|
||||
List<String>` so §B is covered rather than sitting below the seam. Cases: rungs
|
||||
returned in order; missing rungs filtered out; API < 31 coarse-only yields
|
||||
`[network]`; API < 31 with fine yields the full ladder; no compatible provider
|
||||
yields an empty list.
|
||||
|
||||
`hasFine` is **always `false` in production** — the non-goals rule out ever
|
||||
requesting `ACCESS_FINE_LOCATION`. It is a parameter rather than a constant so
|
||||
that the function is total over the permission axis and the API < 31 branch can
|
||||
be tested from both sides, not because fine access is anticipated. If that
|
||||
changes, the ladder is already correct.
|
||||
|
||||
R5 sits below the `locationSource` seam, so a fake source never fires it. It gets
|
||||
its own tests against a mocked `LocationManager` — see *Registration pairing*
|
||||
below.
|
||||
|
||||
**Meter.** `RefCountedSession`: overlapping holders keep the session open;
|
||||
balanced pairs close it; an unmatched release does not drive the count negative.
|
||||
|
||||
Note what this class **cannot** do: it cannot distinguish an unpaired release
|
||||
from a legitimate one, so `acquire → unpaired release` closes the session even
|
||||
while another holder is listening. That is precisely the R5 bug, and
|
||||
`coerceAtLeast(0)` is no defence against it. **The pairing guarantee belongs to
|
||||
`LocationFlow`, not here** — which is why it needs its own test below.
|
||||
|
||||
**Registration pairing (R5).** To make this testable rather than device-only,
|
||||
`LocationFlow` takes a `LocationManager` instead of a `Context`
|
||||
(`LocationFlow(context)` → `LocationFlow(locationManager)`; the caller in
|
||||
`LocationState` does the `getSystemService` lookup). A `mockk<LocationManager>`
|
||||
then covers:
|
||||
|
||||
- every rung throws `SecurityException` → the flow throws and `onListening` fires
|
||||
**neither** edge
|
||||
- `chooseProviders` returns an empty list → same: throws, neither edge
|
||||
- an earlier rung throws and a later one succeeds → registration falls through,
|
||||
exactly one `true`
|
||||
- a successful registration → exactly one `true`, and exactly one `false` plus
|
||||
`removeUpdates` on cancellation
|
||||
- **cancellation mid-seed**, while the `getLastKnownLocation` sweep is in flight
|
||||
→ both edges still fire and `removeUpdates` is still called. This is the case
|
||||
the `try`/`finally` exists for; without it the test fails by hanging the
|
||||
refcount at 1 rather than by throwing, so assert on the edges, not on the
|
||||
absence of an exception.
|
||||
These cover the *semantics* only — the two-thread interleaving that motivates the
|
||||
lock is made unobservable by the lock itself and is not reproduced by any test
|
||||
here.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
On device, re-running the measurement above:
|
||||
|
||||
- **Backgrounded:** after the 5 s grace period, `adb shell dumpsys location`
|
||||
shows no `com.vitorpamplona.amethyst` entry under any provider's `listeners:`,
|
||||
and a `-registration` in the recent-events log.
|
||||
- **Foregrounded:** **one registration per actively-collected flow — at most
|
||||
two**, and one in the steady state where only the "Around Me" feed is live
|
||||
(§C exists precisely because the two flows may overlap). Not four. The coarse
|
||||
registration reads `@+60s0ms` / `minUpdateDistance=500.0` rather than
|
||||
`@+10s0ms` / `100.0`.
|
||||
- The historical aggregates are cumulative since boot; compare deltas across a
|
||||
foreground/background cycle, not absolute totals.
|
||||
- **Invariant:** a subsequent in-app Resource Usage Report shows
|
||||
|
||||
```
|
||||
location.ms ≤ app.fgms + 5 s × (background transitions)
|
||||
```
|
||||
|
||||
Both counters are driven by the same `foregroundTracker.isForeground` flow, so
|
||||
without R2 this would hold exactly. R2 is deliberately the error term: the
|
||||
registration really *is* live during the grace period, so counting it is the
|
||||
honest reading, and a stated fudge factor beats an invariant quietly known to
|
||||
be false. The term is not negligible — the source report shows 6 process
|
||||
starts across two days, and app switches are far more frequent than that — so
|
||||
writing `location.ms ≤ app.fgms` would guarantee that the first person to
|
||||
check it files a bug against this change.
|
||||
|
||||
Second caveat: Finding 4 of the source analysis suspects `app.fgms` of
|
||||
under-reporting, so a violation beyond the grace term indicts that counter
|
||||
rather than this one.
|
||||
- If H1 holds: location works on the API 26 AVD after the change and did not
|
||||
before.
|
||||
|
||||
### Verified on device (2026-07-30, Pixel 9a / Android 17, API 37)
|
||||
|
||||
Measured against the `benchmark` variant — `initWith(release)`, so R8-minified with
|
||||
the shipping proguard rules, installed as `com.vitorpamplona.amethyst.benchmark`
|
||||
beside the untouched Play install. The Play install was force-stopped for the
|
||||
duration so its own (unfixed) registrations could not be mistaken for these.
|
||||
|
||||
| criterion | before | after |
|
||||
|---|---|---|
|
||||
| registrations, foreground | **4** (passive, network, fused, gps) | **1** (fused) |
|
||||
| request profile | `@+10s0ms HIGH_ACCURACY`, `minUpdateDistance=100.0` | `@+1m0s0ms BALANCED`, `minUpdateDistance=500.0` |
|
||||
| registrations, backgrounded | **4**, held while `mWakefulness=Dozing` | **0** |
|
||||
|
||||
Event trace for one full cycle, process alive throughout (pid 28682):
|
||||
|
||||
```
|
||||
17:57:00.117 +registration fused …/40F5A6D7 @+1m0s0ms BALANCED, minUpdateDistance=500.0
|
||||
17:57:33.894 -registration fused …/40F5A6D7 ← HOME pressed, released after the grace
|
||||
17:58:05.798 +registration fused …/091EDA96 @+1m0s0ms BALANCED, minUpdateDistance=500.0
|
||||
(HOME then reopen within 2 s — no -/+ pair; 091EDA96 survives)
|
||||
```
|
||||
|
||||
- **§A gate** — zero registrations while backgrounded, with the process still
|
||||
alive. That is the state the change exists to create; before, four
|
||||
registrations survived screen-off and doze.
|
||||
- **§B request shape** — one provider, top of the ladder (`fused`), at exactly
|
||||
`COARSE_MIN_TIME` / `COARSE_MIN_DISTANCE`. The OS tags it `(COARSE)` and
|
||||
coalesces the effective service request to `@+10m0s0ms LOW_POWER`.
|
||||
- **R2 grace period** — a sub-grace app switch produced **no** teardown/rebuild
|
||||
pair, so a brief switch no longer costs a re-registration and a fresh
|
||||
`getLastKnownLocation` sweep.
|
||||
|
||||
**The OS aggregate after four foreground/background cycles is the headline
|
||||
result**, because it is the same counter shape `location.ms` measures:
|
||||
|
||||
```
|
||||
com.vitorpamplona.amethyst.benchmark:
|
||||
min/max interval = 60s/60s
|
||||
total/active/foreground duration = +2m33s542ms / +2m33s456ms / +2m33s531ms
|
||||
locations = 4
|
||||
```
|
||||
|
||||
Total ≈ active ≈ foreground, all three within 90 ms — against the Play install's
|
||||
`9d14h20m / 8h26m / 8h14m`, where registration was held for 45 % of uptime while
|
||||
only 1.7 % was active. The four foreground windows sum to 153.6 s, matching the
|
||||
aggregate exactly, so nothing is held outside them. Registration-held time now
|
||||
*equals* foreground time, which is precisely what makes `location.ms` honest: the
|
||||
counter measures registration lifetime, and that quantity is no longer divorced
|
||||
from reality.
|
||||
|
||||
**A fix arrives within milliseconds of every re-registration**, which bounds the
|
||||
staleness the coarser profile was feared to introduce:
|
||||
|
||||
```
|
||||
17:57:00.117 +registration → 17:57:00.127 delivered location[1] (10 ms)
|
||||
17:58:05.798 +registration → 17:58:05.802 delivered location[1] ( 4 ms)
|
||||
17:59:09.965 +registration → 17:59:09.967 delivered location[1] ( 2 ms)
|
||||
18:00:09.206 +registration → 18:00:09.213 delivered location[1] ( 7 ms)
|
||||
```
|
||||
|
||||
The `fused` provider hands over its cached fix on registration, so the window in
|
||||
which a returning user could act on a stale geohash is milliseconds, not the 60 s
|
||||
poll interval. Caveat: that cache is warm on this device because Maps and GMS
|
||||
keep it fresh; on a device with no other location consumer it could be colder,
|
||||
which is what `freshestLastKnownLocation` exists to cover.
|
||||
|
||||
**Side-by-side A/B, same device, same instant, both clients backgrounded and
|
||||
running.** The unmodified release client (1.13.1, installed via Obtainium, pid
|
||||
5552) and the benchmark build of this branch (pid 28682) were sampled together:
|
||||
|
||||
```
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[PASSIVE, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
com.vitorpamplona.amethyst/B7B299BE {bg, na} (COARSE) Request[@+10m LOW_POWER, minUpdateDistance=100.0] (inactive)
|
||||
← com.vitorpamplona.amethyst.benchmark: no rows at all
|
||||
```
|
||||
|
||||
Four held registrations versus zero. Note the release client's rows are all
|
||||
`{bg, na} … (inactive)`: the OS has throttled the effective interval to 10
|
||||
minutes and suspended delivery, exactly as §"What the device reports" describes —
|
||||
but the **registration is still held**, and registration-held time is precisely
|
||||
what `location.ms` counts. That is the inflation, visible in one frame.
|
||||
|
||||
Naming note for anyone re-reading the numbers above: both artifacts are `play`
|
||||
**flavor** builds and differ only by buildType, so "the Play install" is an
|
||||
ambiguous label. The unmodified client here is the *release* build, and on this
|
||||
device it came from Obtainium rather than Google Play.
|
||||
|
||||
### Ledger invariant confirmed (2026-07-31, benchmark client, in-app report)
|
||||
|
||||
The acceptance criterion `location.ms ≤ app.fgms + 5 s × transitions` now checks
|
||||
out against accumulated data:
|
||||
|
||||
| | `location.ms` | `app.fgms` | ratio |
|
||||
|---|---|---|---|
|
||||
| release client 1.13.0, day 20663 (before) | 25,660,172 | 9,147 | **2,805×** |
|
||||
| benchmark, day 20664 (permission granted mid-day) | 3m7.3s | 11m31.0s | 0.27× |
|
||||
| benchmark, **day 20665** (granted all day) | **2m10.8s** | **1m56.9s** | **1.12×** |
|
||||
|
||||
Day 20665 is the clean case: `location.ms` exceeds `app.fgms` by 13.9 s, which
|
||||
requires ≥ 3 background transitions to fall inside the grace allowance — met by
|
||||
the report navigation plus an `am start`. Day 20664 independently reconciles with
|
||||
the `dumpsys` measurement: 2m33.5s of OS registration-held + 4 × 5 s grace =
|
||||
~2m53.5s predicted, 3m7.3s actual, the residual being foreground use after the
|
||||
measurement ended. **The ledger and the OS now agree**, where before they were
|
||||
irreconcilable (10.7 h ledger vs 8h26m OS-active over three weeks).
|
||||
|
||||
**Limitation:** this is not a within-package before/after. `location.ms` is
|
||||
absent from days 20648–20663 because the benchmark client had location permission
|
||||
*denied* until 2026-07-30; the "before" is no data, not inflated data.
|
||||
|
||||
### The same data closes the battery question
|
||||
|
||||
Over days 20659–20665 on this device: **5m18s** of location listening against
|
||||
**596 pp** of background battery drain (~85 pp/day). Location cannot be a
|
||||
meaningful contributor at that ratio — Finding 1 is settled, and not in the
|
||||
direction the original analysis assumed.
|
||||
|
||||
Three consumers visible in the same report, none of them location:
|
||||
|
||||
- `service.alwayson.ms` ≈ **23.9 h/day** (148.9 h over 7 days) — an always-on
|
||||
foreground service running essentially continuously. Largest structural
|
||||
difference from a stock client; worth confirming it is deliberately enabled.
|
||||
- **Finding 2, unchanged.** 3,732 relay-hours over 7 days. Day 20664 alone:
|
||||
9,831 successful dials against 25,133 failures = **71.9 %**, matching the
|
||||
original report's 75 %.
|
||||
- **Finding 4, now on cellular.** Day 20664 `net.other.mobile.bg.activems =
|
||||
52,392,613` — **14.6 h** of background mobile active time with **0 requests and
|
||||
0 bytes**. The three-moment `isForeground()` sampling, exactly as diagnosed, so
|
||||
the fg/bg split in this report still cannot be trusted.
|
||||
|
||||
Caveat: the benchmark client's round-the-clock always-on service makes its
|
||||
battery figures non-comparable to a stock install. The relay and `net.other`
|
||||
figures do match the release client's original report closely.
|
||||
|
||||
### Smoke test on a clean install (2026-07-31, benchmark client)
|
||||
|
||||
Uninstalled and reinstalled from branch HEAD so the account, ledger and
|
||||
permission grant all started empty — which is what makes the first-grant and
|
||||
empty-cache paths reachable. UID changed 10805 → 10806, cleanly separating the
|
||||
new data.
|
||||
|
||||
- **R1 — no `Loading` flash on return to foreground: PASS.** Observed directly:
|
||||
granted the permission, set a feed to Around Me, backgrounded for 10 s,
|
||||
reopened — the geohash was present immediately with no blank feed. This was the
|
||||
last open acceptance criterion on the branch and the only one no test could
|
||||
cover. `dumpsys` shows why it works: the fix is delivered 1–6 ms after each
|
||||
re-registration.
|
||||
- **Precise profile live and distinct: PASS.** Geohash screens produce
|
||||
`@+10s0ms BALANCED, minUpdateDistance=100.0`, and the aggregate's `min/max
|
||||
interval` moved from `60s/60s` to `10s/60s`. Both profiles are real in
|
||||
production, not just in the unit tests.
|
||||
- **Refcount under concurrent flows: PASS.** The coarse registration `766C58A4`
|
||||
came up at 15:49:10 and survived **four complete precise-flow cycles**
|
||||
untouched (15:49:41–46, 15:50:47–52, 15:52:07–15:53:05, 15:53:17–22), with
|
||||
both live simultaneously during the first. Opening and closing geohash chats
|
||||
never closes the "Around Me" registration — `RefCountedSession` doing on a real
|
||||
device what `LocationLedgerCompositionTest` asserts.
|
||||
- **No hang in the location picker.** Every precise registration received a fix
|
||||
within ~1 ms; none stuck open. That path does
|
||||
`preciseGeohashStateFlow.first { it is Success }`, which would hang rather than
|
||||
crash if the gate failed to yield.
|
||||
- **Aggregate:** total 6m7.553s / active 6m7.401s (152 ms apart) / foreground
|
||||
5m49.441s. Foreground trails total by 18.1 s across ~7 background transitions,
|
||||
≈ 2.6 s each — the grace period, visible at a scale where it is easy to check.
|
||||
|
||||
**Measurement note:** counting listeners with `grep -c` on the package name is
|
||||
unreliable — the `service: ProviderRequest[…]` summary line also names the
|
||||
package when it is the only requester, inflating the count by one. List the rows
|
||||
and exclude that line instead. A count of zero is unambiguous either way.
|
||||
|
||||
Still not verified: nothing on the gate itself. The `location.ms ≤ app.fgms +
|
||||
5 s × transitions` invariant was separately confirmed from accumulated ledger
|
||||
data (see above); the clean install reset that counter, so it will need another
|
||||
day of use to re-check at scale.
|
||||
|
||||
## Follow-ups (not in this change)
|
||||
|
||||
- **`preciseGeohashStateFlow` is not actually building-level.** With only
|
||||
`ACCESS_COARSE_LOCATION`, Android fuzzes every fix to roughly a 3 km grid, so
|
||||
the 8-char geohash and the location chat channels built on it are far coarser
|
||||
than they claim (`LocationState.kt:104-141`, `GeohashChatScreen.kt:165`,
|
||||
`NewGeohashChatScreen.kt:309`). The profile is kept intact here so the intent
|
||||
survives if the app ever requests `ACCESS_FINE_LOCATION`; whether to request
|
||||
it, or to stop advertising building-level precision, is a separate decision.
|
||||
- **`GeohashChatScreen.kt:161-163` is a one-way permission latch** — it calls
|
||||
`setLocationPermission(true)` inside an `if (isGranted)` rather than passing
|
||||
the boolean, as every other caller does (`LoggedInPage.kt:144`,
|
||||
`LocationAsHash.kt:64`, `NewGeohashChatScreen.kt:285`,
|
||||
`GeohashLocationPickerDialog.kt:270`). Once set, a revoked permission is never
|
||||
reflected back into the shared `LocationState`. Small, in the blast radius,
|
||||
and cheap.
|
||||
- **An `Unavailable` `LocationResult`**, distinct from `LackPermission`, so a
|
||||
device with no usable provider stops being told "No Location Permissions" when
|
||||
it has them. Ten references across five files
|
||||
(`DisplayLocationObserver.kt`, `FeedFilterSpinner.kt`, `HomeScreen.kt`,
|
||||
`NewGeohashChatScreen.kt`, `LocationState.kt`). Belongs with the H1 fix
|
||||
messaging — see the stated decision in §B.
|
||||
- Recording the chosen provider as a ledger counter, so `location.ms` can be
|
||||
read as a cost signal.
|
||||
- Whether Products should still default to `TopFilter.AroundMe`.
|
||||
- Finding 2 — the relay reconnect storm.
|
||||
-2
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst
|
||||
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
@@ -86,7 +85,6 @@ class NotificationFeedFilterModeOverrideTest {
|
||||
signer = NostrSignerInternal(keyPair),
|
||||
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
|
||||
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
|
||||
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
|
||||
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
|
||||
okHttpClientForMoney = { OkHttpClient() },
|
||||
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
|
||||
|
||||
-2
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.thread.ThreadFeedFilter
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
@@ -77,7 +76,6 @@ class ThreadDualAxisChartAssemblerTest {
|
||||
signer = NostrSignerInternal(keyPair),
|
||||
geolocationFlow = { MutableStateFlow<LocationState.LocationResult>(LocationState.LocationResult.Loading) },
|
||||
nwcFilterAssembler = { NWCPaymentFilterAssembler(client) },
|
||||
cashuWalletFilterAssembler = { CashuWalletFilterAssembler(client) },
|
||||
cashuMintDirectoryFilterAssembler = { CashuMintDirectoryFilterAssembler(client) },
|
||||
okHttpClientForMoney = { OkHttpClient() },
|
||||
otsResolverBuilder = { EmptyOtsResolverBuilder.build() },
|
||||
|
||||
+25
-36
@@ -66,28 +66,37 @@ class PlaybackErrorOverlayFitTest {
|
||||
|
||||
private val targetContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
|
||||
/**
|
||||
* Built outside composition on purpose: the mock and its error state are fixtures for the whole
|
||||
* test, not per-composition state. Creating them inside `setContent` would rebuild both on every
|
||||
* recomposition (and trips Compose's UnrememberedMutableState lint).
|
||||
*/
|
||||
private fun failedControllerState() =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun renderInBox(
|
||||
width: Dp,
|
||||
height: Dp,
|
||||
fontScale: Float = 1f,
|
||||
) {
|
||||
val controllerState = failedControllerState()
|
||||
|
||||
rule.setContent {
|
||||
val density = LocalDensity.current.density
|
||||
CompositionLocalProvider(LocalDensity provides Density(density, fontScale)) {
|
||||
Box(Modifier.width(width).height(height)) {
|
||||
RenderPlaybackError(
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
controllerState = controllerState,
|
||||
videoUri = "https://streamstr.net/x/hls/live.m3u8",
|
||||
)
|
||||
}
|
||||
@@ -151,24 +160,14 @@ class PlaybackErrorOverlayFitTest {
|
||||
// button is measured before the weighted text block that absorbs the shortfall. Measure
|
||||
// the same button roomy and then at its tightest, and require the two to agree.
|
||||
val boxHeight = mutableStateOf(400.dp)
|
||||
val controllerState = failedControllerState()
|
||||
|
||||
rule.setContent {
|
||||
val density = LocalDensity.current.density
|
||||
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
|
||||
Box(Modifier.width(322.dp).height(boxHeight.value)) {
|
||||
RenderPlaybackError(
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
controllerState = controllerState,
|
||||
videoUri = "https://streamstr.net/x/hls/live.m3u8",
|
||||
)
|
||||
}
|
||||
@@ -196,24 +195,14 @@ class PlaybackErrorOverlayFitTest {
|
||||
// was just tall enough to keep the icon and not tall enough to pay for it, so the title
|
||||
// rendered sliced. Decoration must yield before words do.
|
||||
val boxHeight = mutableStateOf(400.dp)
|
||||
val controllerState = failedControllerState()
|
||||
|
||||
rule.setContent {
|
||||
val density = LocalDensity.current.density
|
||||
CompositionLocalProvider(LocalDensity provides Density(density, 2f)) {
|
||||
Box(Modifier.width(322.dp).height(boxHeight.value)) {
|
||||
RenderPlaybackError(
|
||||
controllerState =
|
||||
MediaControllerState(
|
||||
controller = mockk<Player>(relaxed = true),
|
||||
playbackError =
|
||||
mutableStateOf(
|
||||
PlaybackException(
|
||||
"Malformed HLS manifest",
|
||||
null,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
),
|
||||
),
|
||||
),
|
||||
controllerState = controllerState,
|
||||
videoUri = "https://streamstr.net/x/hls/live.m3u8",
|
||||
)
|
||||
}
|
||||
|
||||
+10
-6
@@ -18,11 +18,15 @@
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.relay
|
||||
package com.vitorpamplona.amethyst.ui.components
|
||||
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
class RelayTopNavPerRelayFilterSet(
|
||||
val relayUrl: NormalizedRelayUrl,
|
||||
) : IFeedTopNavPerRelayFilterSet
|
||||
/**
|
||||
* No translation service in this flavor, so no note is ever translated and the copy-text
|
||||
* menus never need to offer a "Copy Translated" option.
|
||||
*/
|
||||
fun cachedTranslation(
|
||||
content: String,
|
||||
accountViewModel: AccountViewModel,
|
||||
): String? = null
|
||||
@@ -95,6 +95,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.AuthCoor
|
||||
import com.vitorpamplona.amethyst.service.relayClient.diagnostics.BootRelayDiagnostics
|
||||
import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.NotifyCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger
|
||||
@@ -105,6 +106,7 @@ import com.vitorpamplona.amethyst.service.resourceusage.HttpUsageMeter
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.MeteringNostrSigner
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ProcessCpuSampler
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RadioBurstEstimator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RefCountedSession
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RelayConnectionTimeIntegrator
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.RelayUsageListener
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.ResourceUsageAccountant
|
||||
@@ -246,10 +248,17 @@ class AppModules(
|
||||
OtsSharedPreferences(appContext, applicationIOScope)
|
||||
}
|
||||
|
||||
// App services that should be run as soon as there are subscribers to their flows
|
||||
// App services that should be run as soon as there are subscribers to their
|
||||
// flows. Location additionally releases its OS registration whenever no
|
||||
// activity is started — see the foreground gate inside LocationState.
|
||||
val locationManager by lazy {
|
||||
Log.d("AppModules", "LocationManager Init")
|
||||
LocationState(appContext, applicationIOScope, onListening = { locationSession.setActive(it) })
|
||||
LocationState(
|
||||
appContext,
|
||||
applicationIOScope,
|
||||
isForeground = foregroundTracker.isForeground,
|
||||
onListening = { locationRefCount.setActive(it) },
|
||||
)
|
||||
}
|
||||
val connManager = ConnectivityManager(appContext, applicationIOScope)
|
||||
|
||||
@@ -374,6 +383,11 @@ class AppModules(
|
||||
private val torSession = SessionTimeIntegrator(resourceUsage, UsageKeys.TOR_MS, UsageKeys.TOR_STARTS).also { it.registerFlushHook() }
|
||||
private val locationSession = SessionTimeIntegrator(resourceUsage, UsageKeys.LOCATION_MS).also { it.registerFlushHook() }
|
||||
|
||||
// LocationState exposes two independent flows that can both be listening at
|
||||
// once (the "Around Me" feed plus an open geohash chat). Refcounting keeps
|
||||
// either one stopping from closing the other's segment.
|
||||
private val locationRefCount = RefCountedSession(locationSession::setActive)
|
||||
|
||||
// Time-per-screen (route base names only — arguments never reach the
|
||||
// ledger). Fed by the navigation listener in AppNavigation; foreground
|
||||
// gating means backgrounding on a screen closes its segment.
|
||||
@@ -709,6 +723,7 @@ class AppModules(
|
||||
torManager.status,
|
||||
client,
|
||||
applicationIOScope,
|
||||
onTrigger = { cause -> resourceUsage.add(UsageKeys.relayTrigger(cause), 1) },
|
||||
)
|
||||
|
||||
// Verifies and inserts in the cache from all relays, all subscriptions
|
||||
@@ -871,7 +886,6 @@ class AppModules(
|
||||
AccountCacheState(
|
||||
geolocationFlow = { locationManager.geohashStateFlow },
|
||||
nwcFilterAssembler = { sources.nwc },
|
||||
cashuWalletFilterAssembler = { sources.cashuWallet },
|
||||
cashuMintDirectoryFilterAssembler = { sources.cashuMintDirectory },
|
||||
okHttpClientForMoney = roleBasedHttpClientBuilder::okHttpClientForMoney,
|
||||
contentResolverFn = { appContext.contentResolver },
|
||||
@@ -957,6 +971,12 @@ class AppModules(
|
||||
)
|
||||
}
|
||||
|
||||
// Gives every account that opted into running in the background its own
|
||||
// account-level subscriptions (notifications, DMs, gift wraps, wallet), with no
|
||||
// AccountViewModel behind it. Driven by alwaysOnNotificationServiceManager below,
|
||||
// which already tracks which accounts opted in.
|
||||
val accountSubscriptions = AccountSubscriptionRegistry(sources.account)
|
||||
|
||||
// Manages always-on notification service lifecycle. Preloads every saved
|
||||
// writable account while enabled so GiftWraps for non-active accounts still
|
||||
// get unwrapped by their owning account's newNotesPreProcessor.
|
||||
@@ -966,6 +986,8 @@ class AppModules(
|
||||
scope = applicationIOScope,
|
||||
accountsCache = accountsCache,
|
||||
localPreferences = LocalPreferences,
|
||||
subscriptions = accountSubscriptions,
|
||||
isForeground = foregroundTracker.isForeground,
|
||||
activePubKeyProvider = { sessionManager.loggedInAccount()?.pubKey },
|
||||
)
|
||||
|
||||
|
||||
@@ -165,27 +165,17 @@ object FavoriteAppLauncher {
|
||||
return when (event) {
|
||||
is RootNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
"",
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: "Napplet",
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = "",
|
||||
)
|
||||
is NamedNappletEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
context,
|
||||
event.paths(),
|
||||
event.servers(),
|
||||
event.pubKey,
|
||||
event.identifier(),
|
||||
event.declaredAggregateHash() ?: event.computeAggregateHash(),
|
||||
event.title() ?: event.identifier(),
|
||||
event.requires(),
|
||||
HostProfile.NAPPLET,
|
||||
context = context,
|
||||
manifest = event,
|
||||
authorPubKey = event.pubKey,
|
||||
identifier = event.identifier(),
|
||||
)
|
||||
is RootSiteEvent ->
|
||||
NappletLauncher.buildLaunchParams(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,580 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
/**
|
||||
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
|
||||
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
|
||||
* metadata updates, group messaging, and key-package publishing. MLS state
|
||||
* lives in [MarmotManager]; this class wires it to the account's signer, relay
|
||||
* client, and relay lists. Functions live here (not a ViewModel) so headless
|
||||
* callers - notification receivers, background workers - can drive them.
|
||||
*/
|
||||
class AccountMarmotActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
/**
|
||||
* Resolve the relay set for a Marmot group. Prefer the relays carried in
|
||||
* the MLS GroupContext metadata so every member converges on the same
|
||||
* canonical set; fall back to the account's outbox relays if the group
|
||||
* has none (e.g. a group joined before MIP-01 metadata existed).
|
||||
*
|
||||
* Lives on Account (not AccountViewModel) so that headless callers —
|
||||
* notifications' BroadcastReceiver, background workers — can resolve
|
||||
* relays without spinning up a ViewModel.
|
||||
*/
|
||||
fun marmotGroupRelays(nostrGroupId: HexKey): Set<NormalizedRelayUrl> {
|
||||
val groupRelays =
|
||||
account.marmotManager
|
||||
?.groupMetadata(nostrGroupId)
|
||||
?.relays
|
||||
?.mapNotNull {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
.normalizeOrNull(it)
|
||||
}?.toSet()
|
||||
return if (!groupRelays.isNullOrEmpty()) groupRelays else account.outboxRelays.flow.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message to a Marmot MLS group.
|
||||
* Encrypts the inner event and publishes the GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun sendMarmotGroupMessage(
|
||||
nostrGroupId: HexKey,
|
||||
innerEvent: Event,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
Log.d("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: group=${nostrGroupId.take(8)}… innerKind=${innerEvent.kind} innerId=${innerEvent.id.take(8)}… " +
|
||||
"→ ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
|
||||
Log.d("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
|
||||
}
|
||||
// Link the envelope to the inner message we just encrypted so relay
|
||||
// OK acceptances drill down to the note the chat renders (see
|
||||
// LocalCache.addRelayToNoteAndInners).
|
||||
outbound.signedEvent.innerEventId = innerEvent.id
|
||||
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
|
||||
// Sending a message moves the group out of "New Requests" into
|
||||
// "Known" — do this eagerly before relay round-trip so the UI
|
||||
// updates immediately.
|
||||
account.marmotGroupList.markAsKnown(nostrGroupId)
|
||||
if (groupRelays.isEmpty()) {
|
||||
Log.w("MarmotDbg") {
|
||||
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
|
||||
}
|
||||
}
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a user's KeyPackage from relays and add them to a Marmot group.
|
||||
* Returns a status message describing the outcome.
|
||||
*/
|
||||
@OptIn(kotlin.io.encoding.ExperimentalEncodingApi::class)
|
||||
suspend fun fetchKeyPackageAndAddMember(
|
||||
nostrGroupId: HexKey,
|
||||
memberPubKey: HexKey,
|
||||
): String {
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}…"
|
||||
}
|
||||
val manager = account.marmotManager ?: return "Error: Marmot not initialized"
|
||||
if (!account.isWriteable()) return "Error: Account is read-only"
|
||||
|
||||
// Per MIP-00, invitees advertise the relays that host their
|
||||
// KeyPackages in a kind:10051 KeyPackageRelayListEvent. Look
|
||||
// there first, then fall back to the invitee's NIP-65 outbox
|
||||
// (where KeyPackages typically also land), and finally union
|
||||
// with our own outbox so we still find packages that ended up
|
||||
// on a shared relay.
|
||||
val myOutbox = account.outboxRelays.flow.value
|
||||
val memberKeyPackageRelays =
|
||||
(
|
||||
account.cache
|
||||
.getAddressableNoteIfExists(
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
.createAddress(memberPubKey),
|
||||
)?.event as? com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
|
||||
)?.relays()?.toSet().orEmpty()
|
||||
val memberOutbox =
|
||||
account.cache
|
||||
.getOrCreateUser(memberPubKey)
|
||||
.outboxRelays()
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val fetchRelays =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchRelaysFor(memberKeyPackageRelays, memberOutbox, myOutbox)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: querying ${fetchRelays.size} relay(s) for ${memberPubKey.take(8)}… KeyPackage " +
|
||||
"(memberKeyPackageRelays=${memberKeyPackageRelays.size}, memberOutbox=${memberOutbox.size}, myOutbox=${myOutbox.size}): ${fetchRelays.map { it.url }}"
|
||||
}
|
||||
|
||||
val event =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.fetchKeyPackage(account.client, memberPubKey, fetchRelays)
|
||||
|
||||
if (event == null) {
|
||||
Log.w("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: NO KeyPackage found for ${memberPubKey.take(8)}… on any of ${fetchRelays.size} relay(s)"
|
||||
}
|
||||
return "Error: No KeyPackage found for this user. They may not have published one yet."
|
||||
}
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: got KeyPackage event id=${event.id.take(8)}… kind=${event.kind} authored=${event.pubKey.take(8)}…"
|
||||
}
|
||||
|
||||
val keyPackageBase64 = event.keyPackageBase64()
|
||||
if (keyPackageBase64.isBlank()) {
|
||||
Log.w("MarmotDbg") { "fetchKeyPackageAndAddMember: KeyPackage event has empty content" }
|
||||
return "Error: KeyPackage event has empty content"
|
||||
}
|
||||
|
||||
// The relays embedded in the WelcomeEvent tell the new member
|
||||
// where to subscribe for subsequent GroupEvents. Use our own
|
||||
// outbox — that's where we will publish them.
|
||||
val groupRelays = myOutbox.toList()
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"fetchKeyPackageAndAddMember: addMarmotGroupMember → groupRelays=${groupRelays.size}: ${groupRelays.map { it.url }}"
|
||||
}
|
||||
|
||||
addMarmotGroupMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
keyPackageEvent = event,
|
||||
groupRelays = groupRelays,
|
||||
)
|
||||
|
||||
return "Success: Member added to group"
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a member to a Marmot MLS group.
|
||||
* Publishes the commit GroupEvent, then sends the Welcome gift wrap.
|
||||
*/
|
||||
suspend fun addMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
keyPackageEvent: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent,
|
||||
groupRelays: List<NormalizedRelayUrl>,
|
||||
) {
|
||||
val memberPubKey = keyPackageEvent.pubKey
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: group=${nostrGroupId.take(8)}… member=${memberPubKey.take(8)}… " +
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val (commitEvent, welcomeDelivery) =
|
||||
manager.addMember(
|
||||
nostrGroupId = nostrGroupId,
|
||||
keyPackageEvent = keyPackageEvent,
|
||||
relays = groupRelays,
|
||||
)
|
||||
|
||||
// The MLS commit has already been applied to the local group state —
|
||||
// surface the new member list in the chatroom now so observers (e.g.
|
||||
// MarmotGroupInfoScreen) update without waiting for our own commit to
|
||||
// loop back through the relay.
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: built commit kind=${commitEvent.signedEvent.kind} id=${commitEvent.signedEvent.id.take(8)}… " +
|
||||
"welcomeDelivery=${if (welcomeDelivery != null) "present(giftWrapId=${welcomeDelivery.giftWrapEvent.id.take(8)}…)" else "null"}"
|
||||
}
|
||||
|
||||
// Publish commit first (critical ordering)
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: publishing commit kind:${commitEvent.signedEvent.kind} to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(commitEvent.signedEvent, groupRelays.toSet())
|
||||
|
||||
// Then send the Welcome gift wrap to the new member.
|
||||
//
|
||||
// Use the same delivery path that NIP-17 DMs (kind:1059) take —
|
||||
// computeRelayListToBroadcast() — which has fallbacks for kind:10050
|
||||
// → NIP-65 read → relay hints. Empirically, NIP-17 DMs reach the
|
||||
// invitee, so this path is the one we know works. We also union
|
||||
// with our own outbox + the recipient's dmInboxRelays() as a
|
||||
// belt-and-braces measure in case the cache hasn't been hydrated
|
||||
// yet for this contact.
|
||||
if (welcomeDelivery != null) {
|
||||
val computed = account.broadcaster.computeRelayListToBroadcast(welcomeDelivery.giftWrapEvent)
|
||||
val recipientInbox =
|
||||
account.cache
|
||||
.getOrCreateUser(memberPubKey)
|
||||
.dmInboxRelays()
|
||||
.orEmpty()
|
||||
val relayList = computed + account.outboxRelays.flow.value + recipientInbox
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: welcome gift wrap relay sources " +
|
||||
"computeRelayListToBroadcast=${computed.size} myOutbox=${account.outboxRelays.flow.value.size} " +
|
||||
"recipientInbox=${recipientInbox.size} → union=${relayList.size}"
|
||||
}
|
||||
if (relayList.isEmpty()) {
|
||||
Log.w("MarmotDbg") {
|
||||
"addMarmotGroupMember: NO relays to deliver welcome gift wrap to ${memberPubKey.take(8)}… — welcome will be silently dropped"
|
||||
}
|
||||
} else {
|
||||
Log.d("MarmotDbg") {
|
||||
"addMarmotGroupMember: publishing welcome gift wrap id=${welcomeDelivery.giftWrapEvent.id.take(8)}… " +
|
||||
"kind:${welcomeDelivery.giftWrapEvent.kind} → ${relayList.size} relay(s): ${relayList.map { it.url }}"
|
||||
}
|
||||
}
|
||||
account.client.publish(welcomeDelivery.giftWrapEvent, relayList)
|
||||
} else {
|
||||
Log.w("MarmotDbg") {
|
||||
"addMarmotGroupMember: welcomeDelivery is NULL — invitee ${memberPubKey.take(8)}… will receive nothing!"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Relays where this account publishes kind:30443 KeyPackage events.
|
||||
* Per MIP-00: prefer kind:10051 KeyPackage Relay List; fall back to NIP-65 outbox.
|
||||
*/
|
||||
fun keyPackagePublishRelays(): Set<NormalizedRelayUrl> =
|
||||
com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
|
||||
.publishRelaysFor(account.keyPackageRelayList.flow.value, account.outboxRelays.flow.value)
|
||||
|
||||
/**
|
||||
* Publish or rotate KeyPackage events.
|
||||
*/
|
||||
suspend fun publishMarmotKeyPackages() {
|
||||
val manager =
|
||||
account.marmotManager ?: run {
|
||||
Log.w("MarmotDbg") { "publishMarmotKeyPackages: marmotManager is NULL — no-op" }
|
||||
return
|
||||
}
|
||||
if (!account.isWriteable()) {
|
||||
Log.w("MarmotDbg") { "publishMarmotKeyPackages: account is not writeable — no-op" }
|
||||
return
|
||||
}
|
||||
|
||||
val relays = keyPackagePublishRelays()
|
||||
val needsRotation = manager.needsKeyPackageRotation()
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: needsRotation=$needsRotation relays=${relays.size}"
|
||||
}
|
||||
|
||||
if (needsRotation) {
|
||||
val rotatedEvents = manager.rotateConsumedKeyPackages(relays.toList())
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: rotateConsumedKeyPackages produced ${rotatedEvents.size} event(s)"
|
||||
}
|
||||
rotatedEvents.forEach { event ->
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackages: publishing rotated kind:${event.kind} id=${event.id.take(8)}… " +
|
||||
"→ ${relays.size} relay(s): ${relays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(event, relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and publish initial KeyPackage for this account.
|
||||
*/
|
||||
suspend fun publishMarmotKeyPackage() {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val relays = keyPackagePublishRelays()
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackage: generating + publishing KeyPackage event → ${relays.size} relay(s): ${relays.map { it.url }}"
|
||||
}
|
||||
val event = manager.generateKeyPackageEvent(relays.toList())
|
||||
Log.d("MarmotDbg") {
|
||||
"publishMarmotKeyPackage: signed kind:${event.kind} id=${event.id.take(8)}… authored=${event.pubKey.take(8)}…"
|
||||
}
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, relays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the local user has at least one active KeyPackage bundle and
|
||||
* a published KeyPackage event on relays. Called from [init] after
|
||||
* Marmot state has been restored from disk.
|
||||
*
|
||||
* - If [KeyPackageRotationManager] already has an active bundle (from
|
||||
* the persisted snapshot), we trust the previous session and do
|
||||
* nothing. The matching kind:30443 should already be on relays from
|
||||
* when the bundle was first generated.
|
||||
* - Otherwise we generate a fresh bundle (which is now persisted to
|
||||
* disk by [KeyPackageRotationManager.generateKeyPackage]) and
|
||||
* publish the corresponding event.
|
||||
*
|
||||
* Best-effort: failures are logged but never propagated. We don't want
|
||||
* a flaky relay or missing outbox config at startup to crash account
|
||||
* initialization.
|
||||
*/
|
||||
internal suspend fun ensureMarmotKeyPackagePublished() {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
try {
|
||||
val hasBundle = manager.hasActiveKeyPackages()
|
||||
Log.d("MarmotDbg") {
|
||||
"ensureMarmotKeyPackagePublished: hasActiveKeyPackages=$hasBundle for ${account.signer.pubKey.take(8)}…"
|
||||
}
|
||||
if (hasBundle) {
|
||||
return
|
||||
}
|
||||
Log.d("MarmotDbg") {
|
||||
"ensureMarmotKeyPackagePublished: no active bundle — generating + publishing now"
|
||||
}
|
||||
publishMarmotKeyPackage()
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.w("MarmotDbg", "ensureMarmotKeyPackagePublished failed: ${e.message}", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a KeyPackage has been published in this session.
|
||||
* The d-tag is a randomly-generated value stored in the KeyPackageRotationManager's
|
||||
* persisted snapshot, so there is no fixed address to query in the cache.
|
||||
*/
|
||||
suspend fun hasPublishedKeyPackage(): Boolean {
|
||||
val manager = account.marmotManager ?: return false
|
||||
return manager.hasActiveKeyPackages()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Marmot MLS group.
|
||||
*/
|
||||
suspend fun createMarmotGroup(nostrGroupId: HexKey) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
manager.createGroup(nostrGroupId)
|
||||
// Creator owns the group — mark it as "known" immediately so it
|
||||
// doesn't appear under "New Requests" before the first message.
|
||||
account.marmotGroupList.markAsKnown(nostrGroupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Leave a Marmot MLS group.
|
||||
* Publishes the SelfRemove proposal and removes local state.
|
||||
*
|
||||
* MIP-01/MIP-03: admins MUST first publish a GroupContextExtensions
|
||||
* commit dropping themselves from `admin_pubkeys` before issuing a
|
||||
* SelfRemove proposal. Without that, [MlsGroup.selfRemove] throws
|
||||
* `IllegalStateException("Admin must self-demote via GroupContextExtensions
|
||||
* before SelfRemove (MIP-01)")` and the leave aborts. Demote commit and
|
||||
* SelfRemove proposal both go to the same group relays, demote first so
|
||||
* peers apply it before they see the SelfRemove.
|
||||
*/
|
||||
suspend fun leaveMarmotGroup(
|
||||
nostrGroupId: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId)
|
||||
if (metadata != null && metadata.adminPubkeys.contains(account.signer.pubKey)) {
|
||||
val remaining = metadata.adminPubkeys.filter { it != account.signer.pubKey }.toMutableList()
|
||||
// MIP-03 also rejects any GCE commit that leaves the group with zero
|
||||
// admins. If we're the only one, promote an arbitrary non-self
|
||||
// member to admin before stepping down.
|
||||
if (remaining.isEmpty()) {
|
||||
val heir =
|
||||
manager
|
||||
.memberPubkeys(nostrGroupId)
|
||||
.map { it.pubkey }
|
||||
.firstOrNull { it != account.signer.pubKey }
|
||||
if (heir != null) remaining.add(heir)
|
||||
}
|
||||
if (remaining.isNotEmpty()) {
|
||||
val demoted = metadata.copy(adminPubkeys = remaining)
|
||||
val demoteCommit = manager.updateGroupMetadata(nostrGroupId, demoted)
|
||||
account.client.publish(demoteCommit.signedEvent, groupRelays)
|
||||
}
|
||||
}
|
||||
|
||||
val outbound = manager.leaveGroup(nostrGroupId)
|
||||
// manager.leaveGroup already wiped MLS state, relay subscriptions and
|
||||
// the persisted message log. Drop the in-memory chatroom too — that
|
||||
// releases the strong refs to the decrypted inner notes so LocalCache
|
||||
// (which holds them weakly) can GC them, and the Notification feed
|
||||
// (which iterates account.marmotGroupList.rooms) stops surfacing the group.
|
||||
account.marmotGroupList.removeGroup(nostrGroupId)
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* User-initiated "nuclear" reset for the Marmot subsystem.
|
||||
*
|
||||
* Wipes every MLS group, every retained epoch secret, every persisted
|
||||
* KeyPackage bundle, every relay subscription and every in-memory
|
||||
* chatroom associated with this account. Does NOT broadcast any
|
||||
* SelfRemove/leave commits to peers — if the user is in this flow at
|
||||
* all, local state may already be unusable and a graceful leave is
|
||||
* probably not possible. Peers will see the user as unresponsive until
|
||||
* their next commit evicts the stale leaf.
|
||||
*
|
||||
* A fresh KeyPackage will be republished lazily on the next
|
||||
* `ensureMarmotKeyPackagePublished` cycle, so the account remains
|
||||
* reachable for future group invites.
|
||||
*/
|
||||
suspend fun resetMarmotState() {
|
||||
Log.w("MarmotDbg") { "resetMarmotState(): wiping all Marmot state for ${account.signer.pubKey.take(8)}…" }
|
||||
account.marmotManager?.resetAllState()
|
||||
for (groupId in account.marmotGroupList.allGroupIds()) {
|
||||
account.marmotGroupList.removeGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a member from a Marmot MLS group.
|
||||
* Publishes the commit GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun removeMarmotGroupMember(
|
||||
nostrGroupId: HexKey,
|
||||
targetLeafIndex: Int,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: group=${nostrGroupId.take(8)}… targetLeafIndex=$targetLeafIndex " +
|
||||
"groupRelays=${groupRelays.size}"
|
||||
}
|
||||
val manager =
|
||||
account.marmotManager ?: run {
|
||||
Log.w("MarmotDbg") { "removeMarmotGroupMember: marmotManager is NULL — no-op" }
|
||||
return
|
||||
}
|
||||
if (!account.isWriteable()) {
|
||||
Log.w("MarmotDbg") { "removeMarmotGroupMember: account is not writeable — no-op" }
|
||||
return
|
||||
}
|
||||
|
||||
val outbound = manager.removeMember(nostrGroupId, targetLeafIndex)
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: built commit kind=${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
|
||||
}
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
Log.d("MarmotDbg") {
|
||||
"removeMarmotGroupMember: publishing commit id=${outbound.signedEvent.id.take(8)}… " +
|
||||
"to ${groupRelays.size} relay(s): ${groupRelays.map { it.url }}"
|
||||
}
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Marmot MLS group's metadata (name, description, etc.).
|
||||
* Publishes the commit GroupEvent to group relays.
|
||||
*/
|
||||
suspend fun updateMarmotGroupMetadata(
|
||||
nostrGroupId: HexKey,
|
||||
metadata: com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val outbound = manager.updateGroupMetadata(nostrGroupId, metadata)
|
||||
// The MLS commit has already been applied locally — surface the new
|
||||
// metadata in the chatroom now so the UI reflects it without waiting
|
||||
// for the relay round-trip.
|
||||
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
|
||||
manager.syncMetadataTo(nostrGroupId, chatroom)
|
||||
account.client.publish(outbound.signedEvent, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant admin privileges to [targetPubKey] in a Marmot MLS group by
|
||||
* appending them to `admin_pubkeys` via a GroupContextExtensions commit.
|
||||
*
|
||||
* No-op if the group has no prior metadata (shouldn't happen outside the
|
||||
* first bootstrap commit) or the target is already an admin. Callers
|
||||
* must be an admin themselves — the MLS engine enforces this via the
|
||||
* MIP-03 authorization gate in `enforceAuthorizedProposalSet`.
|
||||
*/
|
||||
suspend fun grantMarmotGroupAdmin(
|
||||
nostrGroupId: HexKey,
|
||||
targetPubKey: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId) ?: return
|
||||
if (metadata.adminPubkeys.contains(targetPubKey)) return
|
||||
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val updated =
|
||||
metadata
|
||||
.copy(adminPubkeys = metadata.adminPubkeys + targetPubKey)
|
||||
.withMergedRelays(outboxRelayStrings)
|
||||
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke admin privileges from [targetPubKey]. Rejects any change that
|
||||
* would leave the group with zero admins — MIP-03's admin-depletion guard
|
||||
* in [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup] would otherwise
|
||||
* throw at commit time.
|
||||
*/
|
||||
suspend fun revokeMarmotGroupAdmin(
|
||||
nostrGroupId: HexKey,
|
||||
targetPubKey: HexKey,
|
||||
groupRelays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val manager = account.marmotManager ?: return
|
||||
if (!account.isWriteable()) return
|
||||
|
||||
val metadata = manager.groupMetadata(nostrGroupId) ?: return
|
||||
if (!metadata.adminPubkeys.contains(targetPubKey)) return
|
||||
val remaining = metadata.adminPubkeys.filter { it != targetPubKey }
|
||||
check(remaining.isNotEmpty()) {
|
||||
"Cannot revoke the last admin from a Marmot group (MIP-03)"
|
||||
}
|
||||
|
||||
val outboxRelayStrings =
|
||||
account.outboxRelays.flow.value
|
||||
.map { it.url }
|
||||
val updated =
|
||||
metadata
|
||||
.copy(adminPubkeys = remaining)
|
||||
.withMergedRelays(outboxRelayStrings)
|
||||
updateMarmotGroupMetadata(nostrGroupId, updated, groupRelays)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.BuzzRelayDialect
|
||||
import com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunPayload
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupDeletions
|
||||
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupMembership
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmHideEvent
|
||||
import com.vitorpamplona.quartz.buzz.dm.DmOpenEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobCancelEvent
|
||||
import com.vitorpamplona.quartz.buzz.jobs.JobRequestEvent
|
||||
import com.vitorpamplona.quartz.buzz.presence.TypingIndicatorEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminAddMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.relayAdmin.RelayAdminRemoveMemberEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalDenyEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.ApprovalGrantEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowDefEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.WorkflowTriggerEvent
|
||||
import com.vitorpamplona.quartz.buzz.workflow.workflowChannel
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_ADMIN
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_ROLE_MEMBER
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_OPEN
|
||||
import com.vitorpamplona.quartz.buzz.workspace.BUZZ_VISIBILITY_PRIVATE
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.PublishResult
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAllWithHooks
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndCollectResults
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.GroupId
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.hTag
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.CreateInviteEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.DeleteGroupEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.EditMetadataEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.PutUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.RemoveUserEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.UpdatePinListEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.moderation.previous
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.JoinRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.request.LeaveRequestEvent
|
||||
import com.vitorpamplona.quartz.nip29RelayGroups.tags.GroupIdTag
|
||||
import com.vitorpamplona.quartz.nip7DThreads.ThreadEvent
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* NIP-29 relay-group and Buzz-workspace orchestration for an [Account]:
|
||||
* join/leave/create/delete/archive groups, threads, invites, pins, member and
|
||||
* role management, metadata edits, plus the Buzz dialect's DMs, jobs,
|
||||
* workflows, and typing signals. Event building lives in quartz builders;
|
||||
* this class wires them to the account's signer and the group's host relay.
|
||||
*/
|
||||
class AccountRelayGroupActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
// All group commands are published ONLY to the group's host relay, where
|
||||
// relay29 authorizes them. The relay is the source of truth; the kind-10009
|
||||
// list is our own cross-device bookkeeping of what we joined.
|
||||
|
||||
/** Send a kind 9021 join request to the group's host relay and remember it. */
|
||||
suspend fun joinRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
code: String? = null,
|
||||
) {
|
||||
val template = JoinRequestEvent.build(channel.groupId.id, inviteCode = code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.follow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a Buzz kind-20002 typing heartbeat for [channel] to its host relay. Ephemeral
|
||||
* (never stored) and fire-and-forget — no delivery tracking, no local echo (we filter
|
||||
* our own typing in the UI). Throttled by the composer to [BuzzTypingState.TYPING_HEARTBEAT_SECS].
|
||||
*/
|
||||
suspend fun sendBuzzTyping(channel: RelayGroupChannel) {
|
||||
if (!account.isWriteable()) return
|
||||
val signed = account.signer.sign(TypingIndicatorEvent.build(channel.groupId.id))
|
||||
account.client.publish(signed, setOf(channel.groupId.relayUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Open (or re-surface) a Buzz DM with [participants] on [relay] via a kind-41010
|
||||
* command. [participants] are the OTHER 1-8 people — the relay adds me, derives the
|
||||
* canonical channel UUID, and confirms with a relay-signed [DmCreatedEvent]
|
||||
* (kind-41001) that lands in [com.vitorpamplona.amethyst.commons.model.buzz.BuzzDmRegistry].
|
||||
* We never assign the channel id ourselves, so callers discover the materialized DM
|
||||
* by watching that registry rather than from this call's return.
|
||||
*/
|
||||
suspend fun openBuzzDm(
|
||||
relay: NormalizedRelayUrl,
|
||||
participants: List<HexKey>,
|
||||
): String? {
|
||||
val signed = account.signer.sign(DmOpenEvent.build(participants))
|
||||
// The relay confirms the DM synchronously in the OK as `response:{"channel_id":"…"}` —
|
||||
// the authoritative, relay-assigned channel UUID (the deployed relay does not emit a
|
||||
// queryable kind-41001). Read it straight from the ack so the caller can open the chat.
|
||||
var results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||
var channelId = buzzDmChannelIdFromAck(results)
|
||||
|
||||
// NIP-42 write race: on a cold connection the relay rejects the first publish with
|
||||
// `auth-required` (our AUTH reply lands async and the write path doesn't re-send). Warm
|
||||
// the connection with a pendingOnAuthRequired read so the auth coordinator completes the
|
||||
// handshake, then retry the publish on the now-authed socket. Mirrors the amy CLI fix.
|
||||
if (channelId == null && results.values.any { !it.accepted && it.message.contains("auth-required", ignoreCase = true) }) {
|
||||
account.client.fetchAllWithHooks(
|
||||
filters = mapOf(relay to listOf(Filter(kinds = listOf(DmOpenEvent.KIND), limit = 1))),
|
||||
idleTimeoutMs = 8_000,
|
||||
pendingOnAuthRequired = true,
|
||||
) { _, _ -> false }
|
||||
results = account.client.publishAndCollectResults(signed, setOf(relay))
|
||||
channelId = buzzDmChannelIdFromAck(results)
|
||||
}
|
||||
return channelId
|
||||
}
|
||||
|
||||
/** The relay-assigned DM channel id from a DM-open OK message (`response:{"channel_id":"…"}`). */
|
||||
private fun buzzDmChannelIdFromAck(results: Map<NormalizedRelayUrl, PublishResult>): String? =
|
||||
results.values
|
||||
.firstOrNull { it.accepted }
|
||||
?.message
|
||||
?.substringAfter("\"channel_id\":\"", "")
|
||||
?.substringBefore('"')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
/** Hide a Buzz DM from my sidebar with a kind-41012 command (re-opening it un-hides). */
|
||||
suspend fun hideBuzzDm(channel: RelayGroupChannel) {
|
||||
val template = DmHideEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Add [member] to an existing group DM with a kind-41011 command (creates a new DM set). */
|
||||
suspend fun addBuzzDmMember(
|
||||
channel: RelayGroupChannel,
|
||||
member: HexKey,
|
||||
) {
|
||||
val template = DmAddMemberEvent.build(channel.groupId.id, member)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* File a Buzz agent job (kind-43001) into channel [channelId] on [relay] — a shared
|
||||
* feature-request the workspace bot can pick up. Untargeted: any agent watching the
|
||||
* channel may accept it. Returns the new job id (the request event id), or null when the
|
||||
* account can't write. See [com.vitorpamplona.amethyst.commons.model.buzz.BuzzJobAggregator].
|
||||
*/
|
||||
suspend fun fileBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
request: String,
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(JobRequestEvent.build(request, channelId, null))
|
||||
// Reflect it locally so the board updates immediately (publish only sends to relays).
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/** Cancel a Buzz job [jobId] with a kind-43005 scoped to [channelId] on [relay]. */
|
||||
suspend fun cancelBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val signed = account.signer.sign(JobCancelEvent.build(jobId, "", channelId))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a Buzz **workflow** run (kind-46020) for [workflowId] into channel [channelId] on
|
||||
* [relay], carrying [task] as the run's request. The trigger's event id IS the run id (and the
|
||||
* approval token), returned here. A run pauses on a human-approval gate before anything ships —
|
||||
* see [com.vitorpamplona.amethyst.commons.model.buzz.WorkflowRunAggregator].
|
||||
*/
|
||||
suspend fun triggerBuzzWorkflow(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
workflowId: String,
|
||||
task: String,
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val content = Json.encodeToString(WorkflowRunPayload(task = task, workflow = workflowId))
|
||||
val signed = account.signer.sign(WorkflowTriggerEvent.build(workflowId, content) { workflowChannel(channelId) })
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a Buzz **workflow definition** (kind-30620) into channel [channelId] on [relay]: an
|
||||
* addressable event whose `d` tag is a freshly-minted workflow UUID (returned here), carrying a
|
||||
* human-readable [name] and the workflow's [yaml] recipe. On a real Buzz relay the relay parses
|
||||
* the YAML and runs it; self-hosted on geode the definition is a named catalog entry the picker
|
||||
* offers and `amy` triggers by id. Returns the new workflow id, or null when the account can't write.
|
||||
*/
|
||||
suspend fun publishBuzzWorkflowDef(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
name: String,
|
||||
yaml: String,
|
||||
): String? {
|
||||
if (!account.isWriteable()) return null
|
||||
val workflowId = RandomInstance.randomChars(16)
|
||||
val signed = account.signer.sign(WorkflowDefEvent.build(workflowId, channelId, yaml, name.ifBlank { null }))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return workflowId
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a paused Buzz workflow run's approval gate (kind-46030). [runId] is the run id, which
|
||||
* doubles as the approval token (the grant's `d` tag). Resuming lets the runner ship the work.
|
||||
* Publishing to the single group [relay]; the runner discovers the decision by author.
|
||||
*/
|
||||
suspend fun approveBuzzWorkflowRun(
|
||||
relay: NormalizedRelayUrl,
|
||||
runId: HexKey,
|
||||
note: String = "",
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(ApprovalGrantEvent.build(runId, note))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/** Deny a paused Buzz workflow run's approval gate (kind-46031); the run is terminal (DENIED). */
|
||||
suspend fun denyBuzzWorkflowRun(
|
||||
relay: NormalizedRelayUrl,
|
||||
runId: HexKey,
|
||||
note: String = "",
|
||||
): HexKey? {
|
||||
if (!account.isWriteable()) return null
|
||||
val signed = account.signer.sign(ApprovalDenyEvent.build(runId, note))
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
return signed.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Upvote a Buzz job [jobId] (authored by [jobAuthor]) — a NIP-25 like (kind-7 `+`) `e`-tagging
|
||||
* the request, `p`-tagging its author and `k`-tagging the reacted kind per NIP-25, and
|
||||
* `h`-scoped to [channelId] so the scheduler (and the board) count it toward priority.
|
||||
*/
|
||||
suspend fun upvoteBuzzJob(
|
||||
relay: NormalizedRelayUrl,
|
||||
channelId: String,
|
||||
jobId: HexKey,
|
||||
jobAuthor: HexKey?,
|
||||
) {
|
||||
if (!account.isWriteable()) return
|
||||
val template =
|
||||
eventTemplate<ReactionEvent>(ReactionEvent.KIND, ReactionEvent.LIKE) {
|
||||
addUnique(ETag.assemble(jobId, null, null))
|
||||
jobAuthor?.let { addUnique(PTag.assemble(it, null)) }
|
||||
addUnique(arrayOf("k", JobRequestEvent.KIND.toString()))
|
||||
addUnique(GroupIdTag.assemble(channelId))
|
||||
}
|
||||
val signed = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(signed)
|
||||
account.client.publish(signed, setOf(relay))
|
||||
}
|
||||
|
||||
/** Send a kind 9022 leave request to the host relay and drop it from our list. */
|
||||
suspend fun leaveRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = LeaveRequestEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.unfollow(channel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the whole group with a kind 9008 delete-group event (owner/admin only — the relay
|
||||
* enforces this). Unlike [leaveRelayGroup], this destroys the channel for everyone rather than
|
||||
* just removing me; the relay drops the group and its messages. Also drops it from our own list
|
||||
* so it disappears from Messages immediately instead of lingering as a now-dead id.
|
||||
*/
|
||||
suspend fun deleteRelayGroup(channel: RelayGroupChannel) {
|
||||
val template = DeleteGroupEvent.build(channel.groupId.id)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
account.unfollow(channel)
|
||||
// Remember the deletion so the channel leaves the community's browse list immediately and
|
||||
// stays gone across a restart — the relay drops the group but our cached 39000 metadata (and a
|
||||
// stale re-announced 44100 on a Buzz relay) would otherwise keep it visible.
|
||||
RelayGroupDeletions.markDeleted(channel.groupId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new group on [relay]: kind 9007 (create-group) then kind 9002
|
||||
* (edit-metadata) with the chosen name/visibility, then remember it. Returns
|
||||
* the new group's id.
|
||||
*/
|
||||
suspend fun createRelayGroup(
|
||||
relay: NormalizedRelayUrl,
|
||||
groupId: String,
|
||||
name: String,
|
||||
about: String? = null,
|
||||
picture: String? = null,
|
||||
isPrivate: Boolean = false,
|
||||
isClosed: Boolean = false,
|
||||
isHidden: Boolean = false,
|
||||
isRestricted: Boolean = false,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = null,
|
||||
channelType: String? = null,
|
||||
): GroupId {
|
||||
// The metadata rides the create event as well as the 9002 below. A plain NIP-29 relay takes
|
||||
// its metadata from the 9002 and ignores these tags; Buzz rejects the 9007 outright without
|
||||
// a `name` (see CreateGroupEvent.build), which used to make "create group" on a Buzz relay
|
||||
// publish two events and produce nothing at all.
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(
|
||||
CreateGroupEvent.build(
|
||||
groupId = groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
visibility = if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN,
|
||||
channelType = channelType,
|
||||
),
|
||||
) { listOf(relay) }
|
||||
|
||||
val edit =
|
||||
EditMetadataEvent.build(
|
||||
groupId,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
parent = parent,
|
||||
)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(edit) { listOf(relay) }
|
||||
|
||||
val id = GroupId(groupId, relay)
|
||||
account.follow(LocalCache.getOrCreateRelayGroupChannel(id))
|
||||
return id
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of NIP-29 status flags to emit on a kind-9002 metadata event. Flags are
|
||||
* presence-only — public/open/visible/unrestricted are simply the ABSENCE of their
|
||||
* restrictive counterpart — so only the enabled restrictive flags are added.
|
||||
*/
|
||||
private fun relayGroupStatus(
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
): Set<GroupMetadataEvent.GroupStatus> =
|
||||
buildSet {
|
||||
if (isPrivate) add(GroupMetadataEvent.GroupStatus.PRIVATE)
|
||||
if (isClosed) add(GroupMetadataEvent.GroupStatus.CLOSED)
|
||||
if (isHidden) add(GroupMetadataEvent.GroupStatus.HIDDEN)
|
||||
if (isRestricted) add(GroupMetadataEvent.GroupStatus.RESTRICTED)
|
||||
}
|
||||
|
||||
/** Post a kind 11 thread (forum-style) to the group, scoped by its `h` tag. */
|
||||
suspend fun postRelayGroupThread(
|
||||
channel: RelayGroupChannel,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
val template =
|
||||
ThreadEvent.build(body, title) {
|
||||
hTag(channel.groupId.id)
|
||||
previous(channel.previousEventRefs(account.pubKey))
|
||||
}
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Mint a kind 9009 invite code for the group (admin/moderator only). */
|
||||
suspend fun createRelayGroupInvite(
|
||||
channel: RelayGroupChannel,
|
||||
code: String,
|
||||
) {
|
||||
val template = CreateInviteEvent.build(channel.groupId.id, code)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the group's pinned-message list with a kind 9010 update-pin-list event
|
||||
* (admin/moderator only). NIP-29 carries the FULL list, so the relay applies it and
|
||||
* republishes the kind-39005 [com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupPinnedEvent].
|
||||
*/
|
||||
suspend fun updateRelayGroupPins(
|
||||
channel: RelayGroupChannel,
|
||||
pinnedEventIds: List<HexKey>,
|
||||
) {
|
||||
val template = UpdatePinListEvent.build(channel.groupId.id, pinnedEventIds)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/** Pin [eventId] by appending it to the current list (no-op if already pinned). */
|
||||
suspend fun pinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
eventId: HexKey,
|
||||
) {
|
||||
if (channel.isPinned(eventId)) return
|
||||
updateRelayGroupPins(channel, channel.pinnedEventIds + eventId)
|
||||
}
|
||||
|
||||
/** Unpin [eventId] by removing it from the current list (no-op if not pinned). */
|
||||
suspend fun unpinRelayGroupMessage(
|
||||
channel: RelayGroupChannel,
|
||||
eventId: HexKey,
|
||||
) {
|
||||
if (!channel.isPinned(eventId)) return
|
||||
updateRelayGroupPins(channel, channel.pinnedEventIds - eventId)
|
||||
}
|
||||
|
||||
/** Kick [pubkey] out of the group with a kind 9001 remove-user event (moderator only). */
|
||||
suspend fun removeRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
val template = RemoveUserEvent.build(channel.groupId.id, listOf(pubkey))
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [pubkey] to the group (or change its roles) with a kind 9000 put-user
|
||||
* event (moderator only). Pass an empty [roles] list for a plain member.
|
||||
*/
|
||||
suspend fun putRelayGroupUser(
|
||||
channel: RelayGroupChannel,
|
||||
pubkey: HexKey,
|
||||
roles: List<String>,
|
||||
) {
|
||||
// Buzz ignores the roles inside the `p` tag and reads a top-level `role` tag instead, in its
|
||||
// own vocabulary — so map ours onto its set before sending. Anything it cannot parse fails
|
||||
// the whole put-user, which is why an unmapped role must become `member` rather than travel.
|
||||
val buzzRole =
|
||||
if (BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)) {
|
||||
when {
|
||||
roles.any { it.equals(RelayGroupMembership.ROLE_ADMIN, true) } -> BUZZ_ROLE_ADMIN
|
||||
else -> BUZZ_ROLE_MEMBER
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val template = PutUserEvent.build(channel.groupId.id, listOf(pubkey to roles), buzzRole = buzzRole)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Add [pubkey] to a Buzz **community** (the whole relay/tenant, not one channel) via the
|
||||
* relay-admin add-member command (kind 9030). Owner/admin only — the relay validates the
|
||||
* sender's role and, on a new insert, updates its NIP-43 membership list (13534). Published to
|
||||
* [relay] with no channel scope.
|
||||
*/
|
||||
suspend fun addCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
role: String? = null,
|
||||
) {
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminAddMemberEvent.build(pubkey, role)) { listOf(relay) }
|
||||
}
|
||||
|
||||
/** Remove [pubkey] from a Buzz community via the relay-admin remove-member command (kind 9031). */
|
||||
suspend fun removeCommunityMember(
|
||||
relay: NormalizedRelayUrl,
|
||||
pubkey: HexKey,
|
||||
) {
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(RelayAdminRemoveMemberEvent.build(pubkey)) { listOf(relay) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit the group's relay-signed metadata with a kind 9002 event (admin only).
|
||||
*
|
||||
* NIP-29 §Subgroups makes the metadata edit a full replacement of the hierarchy
|
||||
* links: a 9002 with no `parent` tag re-roots the group, and one that drops any
|
||||
* existing `child` is rejected by the relay. So unless the caller is explicitly
|
||||
* re-parenting, we re-carry the group's current [parent] and full [children] list
|
||||
* from its latest known metadata to keep the tree intact across a plain name/flag
|
||||
* edit. Pass an explicit value to change them.
|
||||
*/
|
||||
suspend fun editRelayGroupMetadata(
|
||||
channel: RelayGroupChannel,
|
||||
name: String?,
|
||||
about: String?,
|
||||
picture: String?,
|
||||
isPrivate: Boolean,
|
||||
isClosed: Boolean,
|
||||
isHidden: Boolean,
|
||||
isRestricted: Boolean,
|
||||
hashtags: List<String> = emptyList(),
|
||||
geohashes: List<String> = emptyList(),
|
||||
parent: String? = channel.parentGroupId(),
|
||||
children: List<String> = channel.childGroupIds(),
|
||||
) {
|
||||
// On a Buzz relay, visibility rides a `visibility` ("open"/"private") tag — the relay does NOT
|
||||
// read NIP-29's `private` status flag — so a Buzz channel's visibility only actually changes on
|
||||
// edit when we send that tag. A plain NIP-29 relay ignores it and honours the status flag.
|
||||
val isBuzz = BuzzRelayDialect.isBuzz(channel.groupId.relayUrl)
|
||||
val template =
|
||||
EditMetadataEvent.build(
|
||||
channel.groupId.id,
|
||||
name = name,
|
||||
about = about,
|
||||
picture = picture,
|
||||
status = relayGroupStatus(isPrivate, isClosed, isHidden, isRestricted),
|
||||
hashtags = hashtags,
|
||||
geohashes = geohashes,
|
||||
parent = parent,
|
||||
children = children,
|
||||
visibility = if (isBuzz) (if (isPrivate) BUZZ_VISIBILITY_PRIVATE else BUZZ_VISIBILITY_OPEN) else null,
|
||||
)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive or unarchive a Buzz channel (a minimal kind-9002 carrying only the `archived` tag). The
|
||||
* relay hides an archived channel from the sidebar and stamps the 39000, but keeps it and its
|
||||
* history — the reversible counterpart to [deleteRelayGroup]. Admin/owner only; the relay enforces.
|
||||
*/
|
||||
suspend fun archiveRelayGroup(
|
||||
channel: RelayGroupChannel,
|
||||
archived: Boolean,
|
||||
) {
|
||||
val template = EditMetadataEvent.build(channel.groupId.id, archived = archived)
|
||||
account.broadcaster.signAndSendPrivatelyOrBroadcast(template) { channel.relays().toList() }
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
|
||||
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
|
||||
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
|
||||
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
|
||||
@@ -501,6 +503,18 @@ class AccountSettings(
|
||||
return false
|
||||
}
|
||||
|
||||
fun changeHiddenDrawerItems(newItems: Set<NavBarItem>): Boolean {
|
||||
// Sanitize on the way in as well as on the way out: a caller must never be able to persist
|
||||
// Settings as hidden, which would leave no route back to the screen that hides rows.
|
||||
val sanitized = DrawerItemVisibility.sanitize(newItems)
|
||||
if (syncedSettings.navigation.hiddenDrawerItems.value != sanitized) {
|
||||
syncedSettings.navigation.hiddenDrawerItems.tryEmit(sanitized)
|
||||
saveAccountSettings()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** The selected default spend rail across both NWC wallets and CLINK debits. */
|
||||
fun defaultPaymentSource(): PaymentSource? = PaymentSourceResolver.resolveDefault(nwcWallets.value, clinkDebitWallets.value, defaultPaymentSourceId.value)
|
||||
|
||||
|
||||
@@ -25,6 +25,10 @@ import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
|
||||
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
|
||||
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
@@ -83,6 +87,7 @@ class AccountSyncedSettings(
|
||||
val navigation =
|
||||
AccountNavigationPreferences(
|
||||
MutableStateFlow(internalSettings.navigation.bottomBarItems),
|
||||
MutableStateFlow(DrawerItemVisibility.sanitize(navBarItemsFromNames(internalSettings.navigation.hiddenDrawerItems))),
|
||||
)
|
||||
|
||||
fun toInternal(): AccountSyncedSettingsInternal =
|
||||
@@ -124,7 +129,11 @@ class AccountSyncedSettings(
|
||||
.map { it.id }
|
||||
.sorted(),
|
||||
),
|
||||
navigation = AccountNavigationPreferencesInternal(navigation.bottomBarItems.value),
|
||||
navigation =
|
||||
AccountNavigationPreferencesInternal(
|
||||
navigation.bottomBarItems.value,
|
||||
navigation.hiddenDrawerItems.value.toNames(),
|
||||
),
|
||||
)
|
||||
|
||||
fun updateFrom(syncedSettingsInternal: AccountSyncedSettingsInternal) {
|
||||
@@ -221,6 +230,11 @@ class AccountSyncedSettings(
|
||||
if (navigation.bottomBarItems.value != newBottomBarItems) {
|
||||
navigation.bottomBarItems.tryEmit(newBottomBarItems)
|
||||
}
|
||||
|
||||
val newHiddenDrawerItems = DrawerItemVisibility.sanitize(navBarItemsFromNames(syncedSettingsInternal.navigation.hiddenDrawerItems))
|
||||
if (navigation.hiddenDrawerItems.value != newHiddenDrawerItems) {
|
||||
navigation.hiddenDrawerItems.tryEmit(newHiddenDrawerItems)
|
||||
}
|
||||
}
|
||||
|
||||
fun dontTranslateFromFilteredBySpokenLanguages(): Set<String> = languages.dontTranslateFrom.value - getLanguagesSpokenByUser()
|
||||
@@ -322,6 +336,8 @@ class AccountMediaPreferences(
|
||||
@Stable
|
||||
class AccountNavigationPreferences(
|
||||
val bottomBarItems: MutableStateFlow<List<BottomBarEntry>>,
|
||||
/** Drawer rows switched off by the user. Empty = the stock drawer; see DrawerItemVisibility. */
|
||||
val hiddenDrawerItems: MutableStateFlow<Set<NavBarItem>>,
|
||||
)
|
||||
|
||||
@Stable
|
||||
|
||||
+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
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendError
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
|
||||
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
|
||||
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
|
||||
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.launch
|
||||
import java.math.BigDecimal
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val ONCHAIN_BACKEND_NOT_CONFIGURED = "Bitcoin chain backend is not configured"
|
||||
|
||||
/**
|
||||
* Zap and payment orchestration for an [Account]: NIP-57 zap requests, NIP-47
|
||||
* NWC wallet requests (with spoof tracking), NIP-B1 BOLT12 zaps, and NIP-BC
|
||||
* onchain zaps/sends. Event building lives in the commons ZapActions/
|
||||
* Bolt12ZapActions; this class wires wallet selection, signing, and relay
|
||||
* routing to the account.
|
||||
*/
|
||||
class AccountZapActions(
|
||||
private val account: Account,
|
||||
) {
|
||||
suspend fun createZapRequestFor(
|
||||
event: Event,
|
||||
pollOption: Int?,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
toUser: User?,
|
||||
additionalRelays: Set<NormalizedRelayUrl>? = null,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
) = LnZapRequestEvent.create(
|
||||
zappedEvent = event,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
|
||||
signer = account.signer,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
toUserPubHex = toUser?.pubkeyHex,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
suspend fun calculateIfNoteWasZappedByAccount(
|
||||
zappedNote: Note?,
|
||||
afterTimeInSeconds: Long,
|
||||
): Boolean = zappedNote?.isZappedBy(account.userProfile(), afterTimeInSeconds, account) == true
|
||||
|
||||
suspend fun calculateZappedAmount(zappedNote: Note): BigDecimal = zappedNote.zappedAmountWithNWCPayments(account.nip47SignerState)
|
||||
|
||||
suspend fun sendNwcRequest(
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequest(request, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
suspend fun sendNwcRequestToWallet(
|
||||
walletUri: Nip47WalletConnect.Nip47URINorm,
|
||||
request: Request,
|
||||
onResponse: (Response?) -> Unit,
|
||||
): HexKey {
|
||||
val (event, relay) = account.nip47SignerState.sendNwcRequestToWallet(walletUri, request, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
return event.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of spoofed (wrong-author) NIP-47 replies that have arrived for
|
||||
* the given request id. 0 if the request is unknown or already resolved.
|
||||
*/
|
||||
fun nwcSpoofAttempts(requestId: HexKey): Int = LocalCache.paymentTracker.spoofAttemptsFor(requestId)
|
||||
|
||||
/**
|
||||
* Removes a pending NIP-47 request from the tracker. Call this when the
|
||||
* UI gives up waiting (timeout) so the entry doesn't stick around.
|
||||
*/
|
||||
fun cleanupNwcRequest(requestId: HexKey) = LocalCache.paymentTracker.cleanup(requestId)
|
||||
|
||||
suspend fun sendZapPaymentRequestFor(
|
||||
bolt11: String,
|
||||
zappedNote: Note?,
|
||||
onResponse: (Response?) -> Unit,
|
||||
) {
|
||||
val (event, relay) = account.nip47SignerState.sendZapPaymentRequestFor(bolt11, zappedNote, onResponse)
|
||||
account.client.publish(event, setOf(relay))
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the default NWC wallet advertises the nwc#2 `pay` method — the rail a
|
||||
* BOLT12 zap needs to obtain a payer proof. Read from the wallet's cached kind:13194
|
||||
* info event (its capability advertisement), which [NwcSignerState] already refreshes
|
||||
* on wallet change. A missing/unfetched info event reads as false, so the zap path
|
||||
* falls back to lightning rather than attempting a `pay` the wallet can't honor.
|
||||
*/
|
||||
fun defaultWalletSupportsBolt12Pay(): Boolean {
|
||||
val uri = account.nip47SignerState.defaultWalletUri.value ?: return false
|
||||
return account.nip47SignerState.infoCache
|
||||
?.current(uri)
|
||||
?.supportsMethod(NwcMethod.PAY) == true
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a NIP-B1 BOLT12 zap to [recipientPubKey] over the default NWC wallet.
|
||||
*
|
||||
* Signs a kind 9737 intent, pays [offer] via the nwc#2 `pay` method with the
|
||||
* intent-bound `payer_note`, then — only if the wallet returns a payer proof that
|
||||
* validates — builds, self-consumes, and publishes the kind 9736 zap. Validation
|
||||
* is the fail-safe: a wallet that drops or misroutes the note yields a proof that
|
||||
* fails the binding check, so no invalid receipt is ever published (the payment
|
||||
* still happened; [onError] reports "paid, no receipt"). [zappedEvent] is null for
|
||||
* a profile zap. Requires an NWC wallet (see [hasNwcWallet]); BOLT12 zaps have no
|
||||
* external-wallet or LNURL fallback because only NWC returns the proof.
|
||||
*/
|
||||
suspend fun sendBolt12Zap(
|
||||
zappedEvent: Event?,
|
||||
recipientPubKey: HexKey,
|
||||
offer: String,
|
||||
amountMillisats: Long,
|
||||
message: String,
|
||||
zapType: LnZapEvent.ZapType,
|
||||
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
|
||||
onError: (Int, String?) -> Unit,
|
||||
onProcessed: () -> Unit,
|
||||
) {
|
||||
// NONZAP means "pay, but publish no receipt" — settle the offer without binding
|
||||
// a zap intent or emitting a 9736, matching the privacy of a bolt11 NONZAP.
|
||||
if (zapType == LnZapEvent.ZapType.NONZAP) {
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats)) { response ->
|
||||
account.scope.launch {
|
||||
if (response is IErrorResponseLike) onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val anonymous = zapType == LnZapEvent.ZapType.ANONYMOUS
|
||||
// The 9737 intent and the 9736 zap MUST be signed by the same key. An anonymous
|
||||
// zap uses a fresh ephemeral key so it carries no `P` tag and isn't traceable.
|
||||
val zapSigner = if (anonymous) NostrSignerInternal(KeyPair()) else account.signer
|
||||
|
||||
val intent =
|
||||
if (zappedEvent == null) {
|
||||
Bolt12ZapBuilder.buildProfileIntent(zapSigner, recipientPubKey, amountMillisats, offer, message)
|
||||
} else {
|
||||
Bolt12ZapBuilder.buildIntent(zapSigner, recipientPubKey, amountMillisats, offer, EventHintBundle(zappedEvent), message)
|
||||
}
|
||||
|
||||
val payerNote = Bolt12ZapBuilder.payerNote(intent)
|
||||
|
||||
sendNwcRequest(PayMethod.create("bitcoin:?lno=$offer", amountMillisats, payerNote)) { response ->
|
||||
account.scope.launch {
|
||||
// try/finally so a failure while assembling/publishing the receipt (e.g. a
|
||||
// remote signer error) still steps progress and surfaces an error, instead
|
||||
// of vanishing as an uncaught coroutine exception. The payment already
|
||||
// settled at this point, so such a failure means "paid, no receipt".
|
||||
try {
|
||||
when (response) {
|
||||
is PaySuccessResponse -> {
|
||||
val proof = response.result?.payer_proof
|
||||
if (proof.isNullOrBlank()) {
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} else {
|
||||
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
|
||||
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
|
||||
account.cache.justConsumeMyOwnEvent(zap)
|
||||
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
|
||||
} else {
|
||||
onError(R.string.bolt12_zap_invalid_receipt, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is IErrorResponseLike -> onError(R.string.bolt12_payment_failed, response.errorMessage())
|
||||
|
||||
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
|
||||
onError(R.string.bolt12_zap_paid_no_receipt, null)
|
||||
} finally {
|
||||
onProcessed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createZapRequestFor(
|
||||
user: User,
|
||||
message: String = "",
|
||||
zapType: LnZapEvent.ZapType,
|
||||
amountMillisats: Long? = null,
|
||||
lnurl: String? = null,
|
||||
): LnZapRequestEvent {
|
||||
val zapRequest =
|
||||
LnZapRequestEvent.create(
|
||||
userHex = user.pubkeyHex,
|
||||
relays = account.nip65RelayList.inboxFlow.value + (user.inboxRelays() ?: emptyList()),
|
||||
signer = account.signer,
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
amountMillisats = amountMillisats,
|
||||
lnurl = lnurl,
|
||||
)
|
||||
|
||||
account.cache.justConsumeMyOwnEvent(zapRequest)
|
||||
return zapRequest
|
||||
}
|
||||
|
||||
private fun onchainBackendNotConfigured() =
|
||||
OnchainZapSendResult.Failure(
|
||||
OnchainZapSendStage.LOADING_UTXOS,
|
||||
OnchainZapSendError.BACKEND_NOT_CONFIGURED,
|
||||
ONCHAIN_BACKEND_NOT_CONFIGURED,
|
||||
)
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain zap: build a Bitcoin transaction paying the recipient's
|
||||
* derived Taproot address, sign it, broadcast it, and publish the kind:8333
|
||||
* zap receipt. Pass [zappedEvent] to attribute the zap to a specific event, or
|
||||
* leave it null for a profile zap.
|
||||
*/
|
||||
suspend fun sendOnchainZap(
|
||||
recipientPubKey: HexKey,
|
||||
amountSats: Long,
|
||||
feeRateSatPerVByte: Double,
|
||||
comment: String = "",
|
||||
zappedEvent: EventHintBundle<out Event>? = null,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.send(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipientPubKey = recipientPubKey,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
comment = comment,
|
||||
zappedEvent = zappedEvent,
|
||||
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay an explicit Bitcoin address (e.g. a profile's NIP-A3 `bitcoin`
|
||||
* payment target) from the NIP-BC Taproot wallet. A plain wallet send —
|
||||
* no kind:8333 receipt is published. See [OnchainZapSender.sendToAddress].
|
||||
*/
|
||||
suspend fun sendOnchainToAddress(
|
||||
recipientAddress: String,
|
||||
amountSats: Long,
|
||||
feeRateSatPerVByte: Double,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendToAddress(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipientAddress = recipientAddress,
|
||||
amountSats = amountSats,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a NIP-BC onchain split zap: a single Bitcoin transaction paying
|
||||
* each recipient their precomputed share, plus one kind:8333 receipt per
|
||||
* recipient. See [OnchainZapSender.sendSplit] for failure semantics.
|
||||
*/
|
||||
suspend fun sendOnchainZapWithSplits(
|
||||
recipients: List<OnchainZapShare>,
|
||||
feeRateSatPerVByte: Double,
|
||||
comment: String = "",
|
||||
zappedEvent: EventHintBundle<out Event>? = null,
|
||||
): OnchainZapSendResult {
|
||||
val backend =
|
||||
account.cache.onchainBackend
|
||||
?: return onchainBackendNotConfigured()
|
||||
return OnchainZapSender.sendSplit(
|
||||
backend = backend,
|
||||
signer = account.signer,
|
||||
senderPubKey = account.signer.pubKey,
|
||||
recipients = recipients,
|
||||
feeRateSatPerVByte = feeRateSatPerVByte,
|
||||
comment = comment,
|
||||
zappedEvent = zappedEvent,
|
||||
) { template -> account.broadcaster.signAndComputeBroadcast(template) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.buzz.stream.StreamMessageEditEvent
|
||||
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
|
||||
import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUsers
|
||||
import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent
|
||||
import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent
|
||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.quotes.taggedQuoteIds
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent
|
||||
import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore
|
||||
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip85TrustedAssertions.users.ContactCardEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/**
|
||||
* Memory-reclaim policy over the [LocalCache] stores: trims the soft caches,
|
||||
* prunes hidden/old/expired/superseded events, and owns the shared
|
||||
* [unlinkAndRemove] removal primitive that [LocalCache.deleteNote] also relies on.
|
||||
*
|
||||
* Pure policy — it holds no state of its own beyond the cache reference, so every
|
||||
* function can be exercised against a populated cache in tests. Driven by
|
||||
* `MemoryTrimmingService`.
|
||||
*/
|
||||
class CachePruner(
|
||||
private val cache: LocalCache,
|
||||
) {
|
||||
fun cleanMemory() {
|
||||
Log.d("LargeCache") { "Notes cleanup started. Current size: ${cache.notes.size()}" }
|
||||
cache.notes.cleanUp()
|
||||
Log.d("LargeCache") { "Notes cleanup completed. Remaining size: ${cache.notes.size()}" }
|
||||
|
||||
Log.d("LargeCache") { "Addressables cleanup started. Current size: ${cache.addressables.size()}" }
|
||||
cache.addressables.cleanUp()
|
||||
Log.d("LargeCache") { "Addressables cleanup completed. Remaining size: ${cache.addressables.size()}" }
|
||||
|
||||
Log.d("LargeCache") { "Users cleanup started. Current size: ${cache.users.size()}" }
|
||||
cache.users.cleanUp()
|
||||
Log.d("LargeCache") { "Users cleanup completed. Remaining size: ${cache.users.size()}" }
|
||||
}
|
||||
|
||||
fun cleanObservers() {
|
||||
cache.notes.forEach { _, it -> it.clearFlow() }
|
||||
cache.addressables.forEach { _, it -> it.clearFlow() }
|
||||
}
|
||||
|
||||
private fun pruneHiddenMessagesChannel(
|
||||
channel: Channel,
|
||||
account: Account,
|
||||
) {
|
||||
val toBeRemoved = channel.pruneHiddenMessages(account)
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} hidden messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenMessages(account: Account) {
|
||||
cache.ephemeralChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.geohashChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.liveChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.publicChatChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
|
||||
cache.relayGroupChannels.forEach { _, channel ->
|
||||
pruneHiddenMessagesChannel(channel, account)
|
||||
}
|
||||
}
|
||||
|
||||
// 2× the 10-min `PRESENCE_FRESHNESS_WINDOW_SECONDS` used by
|
||||
// `NestsFeedFilter` so a presence still inside any feed's window
|
||||
// can never be pruned.
|
||||
private val presencePruneAgeSeconds = 20L * 60L
|
||||
|
||||
private fun pruneOldMessagesChannel(channel: Channel) {
|
||||
val toBeRemoved = channel.pruneOldMessages()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Audio-room presence is keyed separately from `notes` and
|
||||
// never gets reaped by the top-N rule. Drop entries older
|
||||
// than 2× the 10-min freshness window so the index doesn't
|
||||
// grow unbounded with every author who ever heartbeat here.
|
||||
if (channel is LiveActivitiesChannel) {
|
||||
channel.pruneStalePresence(TimeUtils.now() - presencePruneAgeSeconds)
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 100 || channel.notes.size() > 100) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} old messages removed from ${channel.toBestDisplayName()}. ${channel.notes.size()} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneOldMessages() {
|
||||
checkNotInMainThread()
|
||||
|
||||
cache.ephemeralChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.geohashChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.liveChatChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.publicChatChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.relayGroupChannels.forEach { _, channel ->
|
||||
pruneOldMessagesChannel(channel)
|
||||
}
|
||||
|
||||
cache.chatroomList.forEach { userHex, room ->
|
||||
// History floors are pinned per scope on first advance; null means that window never paged
|
||||
// history, so its cursors hold no position to misalign and nothing needs rewinding. Only the
|
||||
// bands strictly BELOW a floor are this window's responsibility — a pruned message newer than
|
||||
// the floor is the always-on live tail's concern, and rewinding history for it would needlessly
|
||||
// re-page (and, for a busy room straddling the floor, mis-set the boundary). Hence the per-floor
|
||||
// filter when accumulating below.
|
||||
val giftWrapFloor = room.giftWrapHistory.floor
|
||||
val accountNip04Floor = room.nip04History.floor
|
||||
|
||||
room.rooms.map { key, chatroom ->
|
||||
val toBeRemoved = chatroom.pruneMessagesToTheLatestOnly()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
// Newest pruned `created_at` per relay, in each window's cursor space, capped at < floor.
|
||||
// Gift wraps page by the OUTER wrap time (from the rumor-host index); NIP-04 by the event's
|
||||
// own time, and a kind:4 belongs to BOTH the account (rooms-list) and per-conversation cursor.
|
||||
val giftWrapPruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val accountNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
val roomNip04Pruned = HashMap<NormalizedRelayUrl, Long>()
|
||||
// chatroom.nip04History is lazy — only touch (allocate) it when this room actually drops a
|
||||
// kind:4 message, so rooms that never paged conversation history pay nothing.
|
||||
val roomNip04Floor = if (toBeRemoved.any { it.event is PrivateDmEvent }) chatroom.nip04History.floor else null
|
||||
|
||||
toBeRemoved.forEach { note ->
|
||||
when (val ev = note.event) {
|
||||
is BaseDMGroupEvent ->
|
||||
if (giftWrapFloor != null) {
|
||||
val outerUntil = note.rumorHost?.createdAt ?: ev.createdAt
|
||||
if (outerUntil < giftWrapFloor) note.relays.forEach { giftWrapPruned.merge(it, outerUntil, ::maxOf) }
|
||||
}
|
||||
is PrivateDmEvent -> {
|
||||
val until = ev.createdAt
|
||||
if (accountNip04Floor != null && until < accountNip04Floor) note.relays.forEach { accountNip04Pruned.merge(it, until, ::maxOf) }
|
||||
if (roomNip04Floor != null && until < roomNip04Floor) note.relays.forEach { roomNip04Pruned.merge(it, until, ::maxOf) }
|
||||
}
|
||||
}
|
||||
|
||||
childrenToBeRemoved.addAll(removeIfWrap(note))
|
||||
unlinkAndRemove(note)
|
||||
|
||||
childrenToBeRemoved.addAll(note.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
// Realign the windows so a relay that already paged past (or `done` below) the dropped band
|
||||
// re-requests it on the next demand-advance instead of skipping the hole.
|
||||
if (giftWrapPruned.isNotEmpty()) {
|
||||
room.giftWrapHistory.rewindTo(giftWrapPruned)
|
||||
Log.d("DMPagination") { "[giftwrap] window rewound after prune: ${giftWrapPruned.size} relay(s), newest pruned wrap @${giftWrapPruned.values.max()}" }
|
||||
}
|
||||
if (accountNip04Pruned.isNotEmpty()) {
|
||||
room.nip04History.rewindTo(accountNip04Pruned)
|
||||
Log.d("DMPagination") { "[rooms.nip04] window rewound after prune: ${accountNip04Pruned.size} relay(s), newest pruned @${accountNip04Pruned.values.max()}" }
|
||||
}
|
||||
if (roomNip04Pruned.isNotEmpty()) {
|
||||
chatroom.nip04History.rewindTo(roomNip04Pruned)
|
||||
Log.d("DMPagination") { "[convo.nip04] window rewound after prune of ${key.users.joinToString()}: ${roomNip04Pruned.size} relay(s), newest pruned @${roomNip04Pruned.values.max()}" }
|
||||
}
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println(
|
||||
"PRUNE: ${toBeRemoved.size} private messages from $userHex to ${key.users.joinToString()} removed. ${chatroom.messages.size} kept",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeIfWrap(note: Note): List<Note> {
|
||||
val host = note.rumorHost ?: return emptyList()
|
||||
|
||||
val children = mutableListOf<Note>()
|
||||
cache.getNoteIfExists(host.id)?.let { hostNote ->
|
||||
(hostNote.event as? GiftWrapEvent)?.innerEventId?.let { sealId ->
|
||||
cache.getNoteIfExists(sealId)?.let { sealNote ->
|
||||
unlinkAndRemove(sealNote)
|
||||
children.addAll(sealNote.clearChildLinks())
|
||||
}
|
||||
}
|
||||
unlinkAndRemove(hostNote)
|
||||
children.addAll(hostNote.clearChildLinks())
|
||||
}
|
||||
note.rumorHost = null
|
||||
return children
|
||||
}
|
||||
|
||||
fun prunePastVersionsOfReplaceables() {
|
||||
val toBeRemoved =
|
||||
cache.notes.filter { _, note ->
|
||||
val noteEvent = note.event
|
||||
if (noteEvent is AddressableEvent) {
|
||||
noteEvent.createdAt <
|
||||
(
|
||||
cache.addressables
|
||||
.get(noteEvent.address())
|
||||
?.event
|
||||
?.createdAt ?: 0
|
||||
)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
val newerVersion = (it.event as? AddressableEvent)?.address()?.let { tag -> cache.addressables.get(tag) }
|
||||
if (newerVersion != null) {
|
||||
it.moveAllReferencesTo(newerVersion)
|
||||
}
|
||||
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} old version of addressables removed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneRepliesAndReactions(accounts: Set<HexKey>) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val toBeRemoved =
|
||||
cache.notes.filter { _, note ->
|
||||
(
|
||||
(note.event is TextNoteEvent && !note.isNewThread()) ||
|
||||
note.event is ReactionEvent ||
|
||||
note.event is LnZapEvent ||
|
||||
note.event is LnZapRequestEvent ||
|
||||
note.event is ReportEvent ||
|
||||
note.event is GenericRepostEvent
|
||||
) &&
|
||||
note.replyTo?.any { it.flowSet?.isInUse() == true } != true &&
|
||||
note.flowSet?.isInUse() != true &&
|
||||
// don't delete if observing.
|
||||
note.author?.pubkeyHex !in
|
||||
accounts &&
|
||||
// don't delete if it is the logged in account
|
||||
note.event?.isTaggedUsers(accounts) !=
|
||||
true // don't delete if it's a notification to the logged in user
|
||||
}
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (toBeRemoved.size > 1) {
|
||||
println("PRUNE: ${toBeRemoved.size} thread replies removed.")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlinks [note] from everything in the cache that references it, then drops it
|
||||
* from the notes map and notifies observers. This is the shared "unlink from
|
||||
* above" half of removal, used by both the prune callers and [LocalCache.deleteNote].
|
||||
*
|
||||
* It detaches the note from:
|
||||
* - its parent notes (their replies/reactions/zaps/boosts/reports/labels maps);
|
||||
* because event-level reports and torrent comments both carry the target in
|
||||
* `replyTo`, [Note.removeNote] cleans those up here too;
|
||||
* - its channels/gatherers (`inGatherers` is authoritative — `Channel.addNote`
|
||||
* always registers the gatherer — and `getAnyChannel` is a belt-and-suspenders
|
||||
* resolve so a note can never linger in a channel after leaving the cache);
|
||||
* - the per-target indexes `replyTo` does NOT reach: user-level reports and
|
||||
* reported addresses, contact cards, statuses, and poll responses.
|
||||
*
|
||||
* It deliberately does NOT touch the note's own children: prune callers collect
|
||||
* them via [Note.clearChildLinks] and remove the subtree, while [LocalCache.deleteNote]
|
||||
* keeps them and severs only their back-reference. Every per-target removal is
|
||||
* idempotent, so the overlap between `replyTo` and the explicit indexes (e.g. an
|
||||
* event-level report reachable both ways) is harmless. Addressable notes are
|
||||
* dropped from the addressables map by the caller; this only removes from notes.
|
||||
*/
|
||||
fun unlinkAndRemove(note: Note) {
|
||||
note.replyTo?.forEach { masterNote ->
|
||||
masterNote.removeNote(note)
|
||||
}
|
||||
|
||||
note.inGatherers?.forEach { it.removeNote(note) }
|
||||
|
||||
cache.getAnyChannel(note)?.removeNote(note)
|
||||
|
||||
val noteEvent = note.event
|
||||
|
||||
// Quote-repost boosts are tracked outside `replyTo` (see addQuoteBoosts), so
|
||||
// detach this note from every quoted note's boosts here.
|
||||
noteEvent?.taggedQuoteIds()?.forEach { quotedId ->
|
||||
cache.getNoteIfExists(quotedId)?.removeBoost(note)
|
||||
}
|
||||
|
||||
// Edits (1010/3302/40003) are anchored on their target's Note.edits and carry no `replyTo`
|
||||
// back-link, so the unlink above can't reach them — resolve the target by the edit's `e` tag
|
||||
// and drop it there, or a deleted edit would keep overlaying its message.
|
||||
editedTargetIdOf(noteEvent)?.let { cache.getNoteIfExists(it)?.removeEdit(note) }
|
||||
|
||||
// OTS attestations (kind 1040) are likewise anchored on their target's Note.timestamps with
|
||||
// no `replyTo` back-link — resolve the target by the `e` tag and drop the proof there.
|
||||
if (noteEvent is OtsEvent) {
|
||||
noteEvent.digestEventId()?.let { cache.getNoteIfExists(it)?.removeTimestamp(note) }
|
||||
}
|
||||
|
||||
if (noteEvent is ReportEvent) {
|
||||
noteEvent.reportedAuthor().forEach {
|
||||
cache.getUserIfExists(it.pubKey)?.reportsOrNull()?.let { reports ->
|
||||
reports.removeReport(note)
|
||||
reports.removeReportNamingUser(note)
|
||||
}
|
||||
}
|
||||
|
||||
noteEvent.reportedPost().forEach {
|
||||
cache.getNoteIfExists(it.eventId)?.removeReport(note)
|
||||
}
|
||||
|
||||
noteEvent.reportedAddresses().forEach {
|
||||
cache.getAddressableNoteIfExists(it.address)?.removeReport(note)
|
||||
}
|
||||
}
|
||||
|
||||
if (note is AddressableNote && noteEvent is ContactCardEvent) {
|
||||
cache.getUserIfExists(noteEvent.aboutUser())?.cardsOrNull()?.removeCard(note)
|
||||
}
|
||||
|
||||
if (note is AddressableNote && noteEvent is StatusEvent) {
|
||||
note.author?.statusStateOrNull()?.removeStatus(note)
|
||||
}
|
||||
|
||||
if (noteEvent is PollResponseEvent) {
|
||||
noteEvent.poll()?.eventId?.let {
|
||||
cache.getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note)
|
||||
}
|
||||
}
|
||||
|
||||
note.clearFlow()
|
||||
|
||||
cache.notes.remove(note.idHex)
|
||||
|
||||
cache.refreshDeletedNoteObservers(note)
|
||||
}
|
||||
|
||||
/** The id of the message/post an edit event targets (its `e` tag), across all three edit kinds. */
|
||||
private fun editedTargetIdOf(event: Event?): HexKey? =
|
||||
when (event) {
|
||||
is TextNoteModificationEvent -> event.editedNote()?.eventId
|
||||
is ConcordChatEditEvent -> event.editedMessageId()
|
||||
is StreamMessageEditEvent -> event.editedMessage()
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun unlinkAndRemove(nextToBeRemoved: List<Note>) {
|
||||
nextToBeRemoved.forEach { note -> unlinkAndRemove(note) }
|
||||
}
|
||||
|
||||
fun pruneExpiredEvents() {
|
||||
checkNotInMainThread()
|
||||
|
||||
val now = TimeUtils.now()
|
||||
val versionsToBeRemoved = cache.notes.filter { _, it -> it.event?.isExpirationBefore(now) == true }
|
||||
val addressesToBeRemoved = cache.addressables.filter { _, it -> it.event?.isExpirationBefore(now) == true }
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
versionsToBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
addressesToBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
if (versionsToBeRemoved.size > 1 || addressesToBeRemoved.size > 1) {
|
||||
println("PRUNE: ${versionsToBeRemoved.size} events and ${addressesToBeRemoved.size} expired.")
|
||||
}
|
||||
}
|
||||
|
||||
fun pruneHiddenEvents(account: Account) {
|
||||
checkNotInMainThread()
|
||||
|
||||
val childrenToBeRemoved = mutableListOf<Note>()
|
||||
|
||||
val toBeRemoved =
|
||||
account.hiddenUsers.flow.value.hiddenUsers.flatMap { userHex ->
|
||||
(cache.notes.filter { _, it -> it.event?.pubKey == userHex } + cache.addressables.filter { _, it -> it.event?.pubKey == userHex }).toSet()
|
||||
}
|
||||
|
||||
toBeRemoved.forEach {
|
||||
unlinkAndRemove(it)
|
||||
childrenToBeRemoved.addAll(it.clearChildLinks())
|
||||
}
|
||||
|
||||
unlinkAndRemove(childrenToBeRemoved)
|
||||
|
||||
println("PRUNE: ${toBeRemoved.size} messages removed because they were Hidden")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
|
||||
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
|
||||
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.tagValueContains
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodeEventIdAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
|
||||
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip31Alts.AltTag
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.ClientTag
|
||||
import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent
|
||||
import com.vitorpamplona.quartz.utils.DualCase
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
/**
|
||||
* Prefix/content search over the [LocalCache] stores: users, notes, and the
|
||||
* public-chat / ephemeral / live-activity channel maps. Pure read-side policy —
|
||||
* no state beyond the cache reference — so ranking and filtering rules can be
|
||||
* tested against a populated cache.
|
||||
*/
|
||||
class CacheSearch(
|
||||
private val cache: LocalCache,
|
||||
) {
|
||||
fun findUsersStartingWith(
|
||||
username: String,
|
||||
forAccount: Account?,
|
||||
): List<User> {
|
||||
if (username.isBlank()) return emptyList()
|
||||
|
||||
checkNotInMainThread()
|
||||
|
||||
val key = decodePublicKeyAsHexOrNull(username)
|
||||
|
||||
if (key != null) {
|
||||
val user = cache.getUserIfExists(key)
|
||||
if (user != null) {
|
||||
return listOfNotNull(user)
|
||||
}
|
||||
}
|
||||
|
||||
val dualCase =
|
||||
listOf(
|
||||
DualCase(username.lowercase(), username.uppercase()),
|
||||
)
|
||||
|
||||
val finds =
|
||||
cache.users.filter { _, user: User ->
|
||||
val metadata = user.metadataOrNull()
|
||||
if (metadata == null) {
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
} else {
|
||||
(
|
||||
metadata.anyNameOrAddressContains(dualCase) ||
|
||||
user.pubkeyHex.startsWith(username, true) ||
|
||||
user.pubkeyNpub().startsWith(username, true)
|
||||
) &&
|
||||
(forAccount == null || (!forAccount.isHidden(user) && !metadata.anyPropertyContains(forAccount.hiddenUsers.flow.value.hiddenWordsCase)))
|
||||
}
|
||||
}
|
||||
|
||||
val findsFollowing = finds.associateWith { forAccount?.isFollowing(it) == true }
|
||||
val anyNameStartsWith = finds.associateWith { it.metadataOrNull()?.anyNameStartsWith(dualCase) == true }
|
||||
val anyAddressStartsWith = finds.associateWith { it.metadataOrNull()?.anyAddressStartsWith(dualCase) == true }
|
||||
val displayNames = finds.associateWith { it.toBestDisplayName().lowercase() }
|
||||
|
||||
return finds.sortedWith(
|
||||
compareBy(
|
||||
{ findsFollowing[it] == false },
|
||||
{ anyNameStartsWith[it] == false },
|
||||
{ anyAddressStartsWith[it] == false },
|
||||
{ displayNames[it] },
|
||||
{ it.pubkeyHex },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Will return true if supplied note is one of events to be excluded from
|
||||
* search results.
|
||||
*/
|
||||
private fun excludeNoteEventFromSearchResults(note: Note): Boolean =
|
||||
(
|
||||
note.event is GenericRepostEvent ||
|
||||
note.event is RepostEvent ||
|
||||
note.event is CommunityPostApprovalEvent ||
|
||||
note.event is ReactionEvent ||
|
||||
note.event is LnZapEvent ||
|
||||
note.event is LnZapRequestEvent ||
|
||||
note.event is FileHeaderEvent ||
|
||||
note.event is MetadataEvent ||
|
||||
note.event is ContactListEvent ||
|
||||
note.event is AppSpecificDataEvent
|
||||
)
|
||||
|
||||
/**
|
||||
* Tag names whose values should not match text searches: the `client` tag
|
||||
* names the app that published the event (searching for "Amethyst" would
|
||||
* otherwise return every event posted through Amethyst), and `p`/`e`/`a`/`alt`
|
||||
* values are ids or descriptions of other events, not content of this one.
|
||||
*/
|
||||
private val excludedTagNamesFromSearch =
|
||||
setOf(
|
||||
ClientTag.TAG_NAME,
|
||||
PTag.TAG_NAME,
|
||||
ETag.TAG_NAME,
|
||||
ATag.TAG_NAME,
|
||||
AltTag.TAG_NAME,
|
||||
)
|
||||
|
||||
fun findNotesStartingWith(
|
||||
text: String,
|
||||
hiddenUsers: HiddenUsersState,
|
||||
): List<Note> {
|
||||
checkNotInMainThread()
|
||||
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
|
||||
if (key != null) {
|
||||
val note = cache.getNoteIfExists(key)
|
||||
val noteEvent = note?.event
|
||||
val newNote =
|
||||
if (noteEvent is AddressableEvent) {
|
||||
val addressableNote = cache.getAddressableNoteIfExists(noteEvent.address())
|
||||
if (addressableNote?.event?.id == note.idHex) {
|
||||
addressableNote
|
||||
} else {
|
||||
note
|
||||
}
|
||||
} else {
|
||||
note
|
||||
}
|
||||
|
||||
if ((newNote != null) && !excludeNoteEventFromSearchResults(newNote)) {
|
||||
return listOfNotNull(newNote)
|
||||
}
|
||||
}
|
||||
|
||||
return cache.notes.filter { _, note ->
|
||||
if (note.event is AddressableEvent) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (excludeNoteEventFromSearchResults(note)) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (note.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
note.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !note.isHiddenFor(hiddenUsers.flow.value)
|
||||
}
|
||||
|
||||
if (note.event?.isContentEncoded() == false) {
|
||||
return@filter if (!note.isHiddenFor(hiddenUsers.flow.value)) {
|
||||
note.event?.content?.contains(text, true) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
} +
|
||||
cache.addressables.filter { _, addressable ->
|
||||
if (excludeNoteEventFromSearchResults(addressable)) {
|
||||
return@filter false
|
||||
}
|
||||
|
||||
if (addressable.event?.tags?.tagValueContains(text, true, excludedTagNamesFromSearch) == true ||
|
||||
addressable.idHex.startsWith(text, true)
|
||||
) {
|
||||
return@filter !addressable.isHiddenFor(hiddenUsers.flow.value)
|
||||
}
|
||||
|
||||
if (addressable.event?.isContentEncoded() == false) {
|
||||
return@filter if (!addressable.isHiddenFor(hiddenUsers.flow.value)) {
|
||||
addressable.event?.content?.contains(text, true) ?: false
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
return@filter false
|
||||
}
|
||||
}
|
||||
|
||||
fun findPublicChatChannelsStartingWith(text: String): List<PublicChatChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
val key = decodeEventIdAsHexOrNull(text)
|
||||
if (key != null) {
|
||||
cache.getPublicChatChannelIfExists(key)?.let {
|
||||
return listOf(it)
|
||||
}
|
||||
}
|
||||
|
||||
return cache.publicChatChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun findEphemeralChatChannelsStartingWith(text: String): List<EphemeralChatChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
return cache.ephemeralChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
|
||||
fun findLiveActivityChannelsStartingWith(text: String): List<LiveActivitiesChannel> {
|
||||
if (text.isBlank()) return emptyList()
|
||||
|
||||
try {
|
||||
val parsed = Nip19Parser.uriToRoute(text)?.entity
|
||||
if (parsed is NAddress && parsed.kind == LiveActivitiesEvent.KIND) {
|
||||
return listOf(cache.getOrCreateLiveChannel(parsed.address()))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
}
|
||||
|
||||
return cache.liveChatChannels.filter { _, channel ->
|
||||
channel.anyNameStartsWith(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,15 @@ sealed interface ConcordInviteResult {
|
||||
*/
|
||||
data object Expired : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The link opens, but this community's roster has banned us (CORD-04).
|
||||
*
|
||||
* A Refounding re-mints every outstanding link onto the new root, and a removed member keeps the
|
||||
* URL and its unlock token forever — so honouring the link alone would hand the new keys to the
|
||||
* very account the rotation expelled.
|
||||
*/
|
||||
data object Banned : ConcordInviteResult
|
||||
|
||||
/**
|
||||
* The bundle event was found but could not be opened with the link's token —
|
||||
* typically because it was minted by a newer/incompatible Concord client whose
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
/**
|
||||
* The minimal get-or-create surface of the event cache, used by callers (like
|
||||
* `NewMessageTagger`) that resolve user/note references while composing without
|
||||
* needing the full [LocalCache] API.
|
||||
*/
|
||||
interface Dao {
|
||||
fun getOrCreateUser(pubkey: HexKey): User
|
||||
|
||||
fun getOrCreateNote(hex: HexKey): Note
|
||||
|
||||
fun getOrCreateAddressableNote(address: Address): AddressableNote?
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchFirst
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.bookmarkList.OldBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
|
||||
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
|
||||
|
||||
/**
|
||||
* The sign-and-publish choke point for an [Account]: computes the relay set an
|
||||
* event should be broadcast to (NIP-65 outbox model, relay hints, channel home
|
||||
* relays, broadcast lists, DM inboxes) and owns every publish path - automatic,
|
||||
* outbox-only, everywhere, private-relay-list, anonymous, and rebroadcast.
|
||||
*
|
||||
* Feature orchestration on [Account] (and the Account*Actions classes) should
|
||||
* funnel every publish through this class instead of calling the relay client
|
||||
* directly.
|
||||
*/
|
||||
class EventBroadcaster(
|
||||
private val account: Account,
|
||||
) {
|
||||
private fun computeRelayListForLinkedUser(user: User): Set<NormalizedRelayUrl> =
|
||||
if (user == account.userProfile()) {
|
||||
account.notificationRelays.flow.value
|
||||
} else {
|
||||
user.inboxRelays()?.ifEmpty { null }?.toSet()
|
||||
?: (
|
||||
account.cache.relayHints
|
||||
.hintsForKey(user.pubkeyHex)
|
||||
.toSet() + user.allUsedRelays()
|
||||
)
|
||||
}
|
||||
|
||||
private fun computeRelayListForLinkedUser(pubkey: HexKey): Set<NormalizedRelayUrl> =
|
||||
if (pubkey == account.userProfile().pubkeyHex) {
|
||||
account.notificationRelays.flow.value
|
||||
} else {
|
||||
account.cache
|
||||
.getUserIfExists(pubkey)
|
||||
?.inboxRelays()
|
||||
?.ifEmpty { null }
|
||||
?.toSet()
|
||||
?: account.cache.relayHints
|
||||
.hintsForKey(pubkey)
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun computeRelaysForChannels(event: Event): Set<NormalizedRelayUrl> = account.cache.getAnyChannel(event)?.relays() ?: emptySet()
|
||||
|
||||
// Personal events the user stores just for themselves — drafts, app settings, bookmark
|
||||
// lists — and channel/community events that already declare their own home relays
|
||||
// should not be replicated to the user's broadcasting relays. Channel/community events
|
||||
// that don't define any home relays fall through to broadcast, since there's nowhere
|
||||
// else for them to land.
|
||||
private fun wantsBroadcastRelays(event: Event): Boolean {
|
||||
if (event is DraftWrapEvent ||
|
||||
event is AppSpecificDataEvent ||
|
||||
event is BookmarkListEvent ||
|
||||
event is OldBookmarkListEvent ||
|
||||
event is LabeledBookmarkListEvent
|
||||
) {
|
||||
return false
|
||||
}
|
||||
if (event is PollEvent && event.relays().isNotEmpty()) return false
|
||||
if (event is MeetingSpaceEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
if (event is MeetingRoomEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
if (event is LiveActivitiesEvent && event.allRelayUrls().isNotEmpty()) return false
|
||||
|
||||
val channelRelays = account.cache.getAnyChannel(event)?.relays()
|
||||
if (channelRelays != null && channelRelays.isNotEmpty()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
fun computeRelayListToBroadcast(event: Event): Set<NormalizedRelayUrl> = computeRelayListToBroadcast(event, mutableSetOf())
|
||||
|
||||
private fun computeRelayListToBroadcast(
|
||||
event: Event,
|
||||
visited: MutableSet<HexKey>,
|
||||
): Set<NormalizedRelayUrl> {
|
||||
// a-tagged events can form cycles; without this the two recursive descents stack-overflow.
|
||||
if (!visited.add(event.id)) return emptySet()
|
||||
|
||||
if (event is GiftWrapEvent) {
|
||||
val receiver = event.recipientPubKey()
|
||||
return if (receiver != null) {
|
||||
val relayList =
|
||||
account.cache
|
||||
.getOrCreateUser(receiver)
|
||||
.dmInboxRelayList()
|
||||
?.relays()
|
||||
?.ifEmpty { null }
|
||||
relayList?.toSet() ?: computeRelayListForLinkedUser(receiver)
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
}
|
||||
// Seals, inner DM messages, and unsigned rumors never get broadcast
|
||||
// relays: they only travel inside gift wraps.
|
||||
if (event is SealedRumorEvent || event is BaseDMGroupEvent || event.sig.isEmpty()) {
|
||||
return emptySet()
|
||||
}
|
||||
|
||||
val includeBroadcast = wantsBroadcastRelays(event)
|
||||
val broadcastRelays = if (includeBroadcast) account.broadcastRelayList.flow.value else emptySet()
|
||||
|
||||
if (event is MetadataEvent || event is AdvertisedRelayListEvent) {
|
||||
// everywhere
|
||||
return account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value + broadcastRelays
|
||||
}
|
||||
|
||||
val relayList = mutableSetOf<NormalizedRelayUrl>()
|
||||
relayList.addAll(broadcastRelays)
|
||||
|
||||
val author = account.cache.getUserIfExists(event.pubKey)
|
||||
|
||||
if (author != null) {
|
||||
if (author == account.userProfile()) {
|
||||
if (includeBroadcast) {
|
||||
relayList.addAll(account.outboxRelays.flow.value)
|
||||
} else {
|
||||
// account.outboxRelays mixes in the broadcast list; for personal/channel events
|
||||
// we want the user's NIP-65 / private / local outbox without it.
|
||||
relayList.addAll(account.nip65RelayList.outboxFlow.value)
|
||||
relayList.addAll(account.privateStorageRelayList.flow.value)
|
||||
relayList.addAll(account.localRelayList.flow.value)
|
||||
}
|
||||
} else {
|
||||
val relays =
|
||||
author.outboxRelays()?.ifEmpty { null }
|
||||
?: author.allUsedRelaysOrNull()
|
||||
?: account.cache.relayHints.hintsForKey(author.pubkeyHex)
|
||||
|
||||
relayList.addAll(relays)
|
||||
}
|
||||
} else {
|
||||
relayList.addAll(account.cache.relayHints.hintsForKey(event.pubKey))
|
||||
}
|
||||
|
||||
if (event is PubKeyHintProvider) {
|
||||
event.pubKeyHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedPubKeys().forEach { pubkey ->
|
||||
relayList.addAll(computeRelayListForLinkedUser(pubkey))
|
||||
}
|
||||
}
|
||||
|
||||
if (event is EventHintProvider) {
|
||||
event.eventHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedEventIds().forEach { eventId ->
|
||||
account.cache.getNoteIfExists(eventId)?.let { linkedNote ->
|
||||
val linkedNoteAuthor = linkedNote.author
|
||||
|
||||
if (linkedNoteAuthor != null) {
|
||||
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
|
||||
} else {
|
||||
relayList.addAll(linkedNote.relays.toSet())
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is AddressHintProvider) {
|
||||
event.addressHints().forEach {
|
||||
relayList.add(it.relay)
|
||||
}
|
||||
event.linkedAddressIds().forEach { addressId ->
|
||||
account.cache.getAddressableNoteIfExists(addressId)?.let { linkedNote ->
|
||||
val linkedNoteAuthor = linkedNote.author
|
||||
|
||||
if (linkedNoteAuthor != null) {
|
||||
relayList.addAll(computeRelayListForLinkedUser(linkedNoteAuthor))
|
||||
} else {
|
||||
relayList.addAll(linkedNote.relays.toSet())
|
||||
}
|
||||
|
||||
linkedNote.event?.let { linkedEvent ->
|
||||
relayList.addAll(computeRelayListToBroadcast(linkedEvent, visited))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event is PollEvent) {
|
||||
relayList.addAll(event.relays())
|
||||
}
|
||||
|
||||
if (event is MeetingSpaceEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
if (event is MeetingRoomEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
if (event is LiveActivitiesEvent) {
|
||||
relayList.addAll(event.allRelayUrls())
|
||||
}
|
||||
|
||||
relayList.addAll(computeRelaysForChannels(event))
|
||||
|
||||
return relayList
|
||||
}
|
||||
|
||||
fun computeRelayListToBroadcast(note: Note): Set<NormalizedRelayUrl> {
|
||||
val noteEvent = note.event
|
||||
return if (noteEvent != null) {
|
||||
computeRelayListToBroadcast(noteEvent)
|
||||
} else {
|
||||
note.relays.toSet()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun broadcast(note: Note) {
|
||||
note.event?.let { noteEvent ->
|
||||
val host = note.rumorHost
|
||||
if (host != null) {
|
||||
// Rumors are rebroadcast as their delivering envelope: the
|
||||
// cached copy is content-stripped, so download it and send it.
|
||||
// A just-sent note has no relays until its self-wrap echoes
|
||||
// back — fall back to our own DM inbox relays. Bare seals
|
||||
// (kind 13) carry no p tag, so that filter is wrap-only.
|
||||
val relays =
|
||||
note.relays.ifEmpty {
|
||||
account.dmRelays.flow.value
|
||||
.toList()
|
||||
}
|
||||
val filter =
|
||||
if (host.kind == SealedRumorEvent.KIND) {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
} else {
|
||||
Filter(
|
||||
kinds = listOf(host.kind),
|
||||
tags = mapOf("p" to listOf(account.pubKey)),
|
||||
ids = listOf(host.id),
|
||||
)
|
||||
}
|
||||
account.client
|
||||
.fetchFirst(
|
||||
filters = relays.associateWith { _ -> listOf(filter) },
|
||||
)?.let { downloadedEvent ->
|
||||
val toRelays = computeRelayListToBroadcast(downloadedEvent)
|
||||
account.client.publish(downloadedEvent, toRelays)
|
||||
}
|
||||
} else if (noteEvent.sig.isEmpty()) {
|
||||
// Rumor with no known wrap: publishing it would disclose the
|
||||
// private content to relays even though they reject the
|
||||
// missing signature.
|
||||
return
|
||||
} else {
|
||||
account.client.publish(noteEvent, computeRelayListToBroadcast(note))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendAutomatic(events: List<Event>) = events.forEach { sendAutomatic(it) }
|
||||
|
||||
fun sendAutomatic(event: Event?) {
|
||||
if (event == null) return
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, computeRelayListToBroadcast(event))
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(event: Event?) {
|
||||
if (event == null) return
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, account.outboxRelays.flow.value)
|
||||
}
|
||||
|
||||
fun sendMyPublicAndPrivateOutbox(events: List<Event>) {
|
||||
events.forEach {
|
||||
account.client.publish(it, account.outboxRelays.flow.value)
|
||||
account.cache.justConsumeMyOwnEvent(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLiterallyEverywhere(event: Event) {
|
||||
account.client.publish(event, account.followPlusAllMineWithIndex.flow.value + account.client.availableRelaysFlow().value)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
account.client.publish(event, relayList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign [template] with an arbitrary [signer] (e.g. a per-geohash ephemeral
|
||||
* identity that is deliberately NOT this account's key) and publish to exactly
|
||||
* [relayList]. Used by geohash location chat, where authorship inside a cell
|
||||
* must not be linkable to the user's npub.
|
||||
*/
|
||||
suspend fun <T : Event> signWithAndSendPrivately(
|
||||
template: EventTemplate<T>,
|
||||
signer: NostrSigner,
|
||||
relayList: Set<NormalizedRelayUrl>,
|
||||
): T {
|
||||
val event = signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
if (relayList.isNotEmpty()) account.client.publish(event, relayList)
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndSendPrivatelyOrBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
relayList: (T) -> List<NormalizedRelayUrl>?,
|
||||
): T {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val relays = relayList(event)
|
||||
val targets =
|
||||
if (!relays.isNullOrEmpty()) {
|
||||
relays.toSet()
|
||||
} else {
|
||||
computeRelayListToBroadcast(event)
|
||||
}
|
||||
account.chatDeliveryTracker.trackPublic(event.id, targets)
|
||||
account.client.publish(event, targets)
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAndComputeBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
): T {
|
||||
val event = account.signer.sign(template)
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val note =
|
||||
if (event is AddressableEvent) {
|
||||
account.cache.getOrCreateAddressableNote(event.address())
|
||||
} else {
|
||||
account.cache.getOrCreateNote(event.id)
|
||||
}
|
||||
|
||||
val relayList = computeRelayListToBroadcast(note)
|
||||
|
||||
account.client.publish(event, relayList)
|
||||
|
||||
broadcast.forEach { account.client.publish(it, relayList) }
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
suspend fun <T : Event> signAnonymouslyAndBroadcast(
|
||||
template: EventTemplate<T>,
|
||||
broadcast: List<Event> = emptyList(),
|
||||
anonymousSigner: NostrSigner = NostrSignerInternal(KeyPair()),
|
||||
): T {
|
||||
val event = anonymousSigner.sign(template)
|
||||
|
||||
account.cache.justConsumeMyOwnEvent(event)
|
||||
val note =
|
||||
if (event is AddressableEvent) {
|
||||
account.cache.getOrCreateAddressableNote(event.address())
|
||||
} else {
|
||||
account.cache.getOrCreateNote(event.id)
|
||||
}
|
||||
|
||||
val relayList = computeRelayListToBroadcast(note)
|
||||
|
||||
account.client.publish(event, relayList)
|
||||
|
||||
broadcast.forEach { account.client.publish(it, relayList) }
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
fun republishEventsTo(
|
||||
events: List<Event>,
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
) {
|
||||
if (relays.isEmpty() || events.isEmpty()) return
|
||||
events.forEach { account.client.publish(it, relays) }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
-2
@@ -59,7 +59,6 @@ import java.io.File
|
||||
class AccountCacheState(
|
||||
val geolocationFlow: () -> StateFlow<LocationState.LocationResult>,
|
||||
val nwcFilterAssembler: () -> NWCPaymentFilterAssembler,
|
||||
val cashuWalletFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler,
|
||||
val cashuMintDirectoryFilterAssembler: () -> com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler,
|
||||
val okHttpClientForMoney: (String) -> okhttp3.OkHttpClient,
|
||||
val contentResolverFn: () -> ContentResolver,
|
||||
@@ -266,7 +265,6 @@ class AccountCacheState(
|
||||
signer = signerWithClientTag,
|
||||
geolocationFlow = geolocationFlow,
|
||||
nwcFilterAssembler = nwcFilterAssembler,
|
||||
cashuWalletFilterAssembler = cashuWalletFilterAssembler,
|
||||
cashuMintDirectoryFilterAssembler = cashuMintDirectoryFilterAssembler,
|
||||
okHttpClientForMoney = okHttpClientForMoney,
|
||||
otsResolverBuilder = otsResolverBuilder,
|
||||
|
||||
+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 =
|
||||
|
||||
+5
-38
@@ -28,8 +28,6 @@ import com.vitorpamplona.amethyst.commons.cashu.ops.RestoreOutcome
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.SendTokenCompleted
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.TokenEntry
|
||||
import com.vitorpamplona.amethyst.commons.cashu.ops.describeMintError
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletQueryState
|
||||
import com.vitorpamplona.amethyst.model.AccountSettings
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -102,7 +100,6 @@ class CashuWalletState(
|
||||
private val signer: NostrSigner,
|
||||
private val cache: LocalCache,
|
||||
private val scope: CoroutineScope,
|
||||
private val assembler: CashuWalletFilterAssembler,
|
||||
private val outboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val inboxRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
private val dmRelaysFlow: StateFlow<Set<NormalizedRelayUrl>>,
|
||||
@@ -391,7 +388,6 @@ class CashuWalletState(
|
||||
// Lifecycle
|
||||
// ============================================================
|
||||
private val jobs = mutableListOf<Job>()
|
||||
private var currentSubscription: CashuWalletQueryState? = null
|
||||
|
||||
@Volatile private var started = false
|
||||
|
||||
@@ -469,21 +465,11 @@ class CashuWalletState(
|
||||
// our own kind:10019 — and another client may have published that
|
||||
// with relays unrelated to our NIP-65 lists — so we listen on the
|
||||
// union of those plus our NIP-65 inbox + DM relays.
|
||||
jobs +=
|
||||
scope.launch(Dispatchers.IO) {
|
||||
combine(
|
||||
outboxRelaysFlow,
|
||||
inboxRelaysFlow,
|
||||
dmRelaysFlow,
|
||||
_nutzapInfoEvent,
|
||||
) { outbox, inbox, dm, info ->
|
||||
CashuWalletQueryState(
|
||||
pubkey = pubKey,
|
||||
ownEventRelays = outbox,
|
||||
inboxRelays = inbox + dm + (info?.relays() ?: emptyList()),
|
||||
)
|
||||
}.collect { syncSubscription(it) }
|
||||
}
|
||||
// The relay subscription for this wallet is NOT here. It lives in
|
||||
// CashuWalletEoseManager, inside the account-level assembler group, so it mounts and
|
||||
// unmounts with every other account-level loader instead of running for the whole life of
|
||||
// the Account object. What stays below is wallet *state*: indexing what arrives, and the
|
||||
// local bookkeeping around it.
|
||||
|
||||
// Reactive incremental update: any new event arrival that matches our
|
||||
// pubkey + the NIP-60/61 kinds we care about gets indexed.
|
||||
@@ -538,25 +524,6 @@ class CashuWalletState(
|
||||
fun destroy() {
|
||||
jobs.forEach { it.cancel() }
|
||||
jobs.clear()
|
||||
currentSubscription?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Subscription management
|
||||
// ============================================================
|
||||
private fun syncSubscription(next: CashuWalletQueryState) {
|
||||
val previous = currentSubscription
|
||||
if (next.ownEventRelays.isEmpty() && next.inboxRelays.isEmpty()) {
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = null
|
||||
return
|
||||
}
|
||||
if (previous == next) return // unchanged
|
||||
|
||||
previous?.let { runCatching { assembler.unsubscribe(it) } }
|
||||
currentSubscription = next
|
||||
assembler.subscribe(next)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
+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
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
|
||||
+2
-1
@@ -20,8 +20,9 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.allFollows
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
-2
@@ -21,11 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
+2
-2
@@ -21,10 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
+1
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.compute50kmRange
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.favoriteAlgoFeeds
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.favoriteAlgoFeeds.FavoriteAlgoFeedTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.global
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.hashtag
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.CommunityRelayLoader
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.AuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.community
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.OutboxRelayLoader
|
||||
|
||||
+2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilter
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.relay
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.relay.RelayTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
+1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.amethyst.model.topNavFeeds.unknown
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.unknown.UnknownTopNavPerRelayFilterSet
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.TopFilter
|
||||
import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter
|
||||
|
||||
+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
|
||||
}
|
||||
+5
-3
@@ -50,6 +50,7 @@ import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.napplet.NappletConsentSummary
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNotificationStore
|
||||
import com.vitorpamplona.amethyst.napplet.NappletRelayCleartext
|
||||
import com.vitorpamplona.amethyst.napplet.buildConnectInfo
|
||||
import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
|
||||
@@ -255,7 +256,7 @@ class AccountNappletGateways(
|
||||
emptyList()
|
||||
} else {
|
||||
runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, timeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
|
||||
account.client.fetchAll(filters = relays.associateWith { filters }, idleTimeoutMs = QUERY_TIMEOUT.inWholeMilliseconds)
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
val fromCache = filters.flatMap { filter -> account.cache.filter(filter).mapNotNull { it.event } }
|
||||
@@ -265,7 +266,8 @@ class AccountNappletGateways(
|
||||
.distinctBy { it.id }
|
||||
.sortedByDescending { it.createdAt }
|
||||
val limit = filters.mapNotNull { it.limit }.maxOrNull()
|
||||
return limit?.let { merged.take(it) } ?: merged
|
||||
val limited = limit?.let { merged.take(it) } ?: merged
|
||||
return limited.mapNotNull { NappletRelayCleartext.forDelivery(it, account.signer) }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -279,7 +281,7 @@ class AccountNappletGateways(
|
||||
}
|
||||
|
||||
val result = CompletableDeferred<String?>()
|
||||
account.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(invoice, null) { response ->
|
||||
when (response) {
|
||||
is PayInvoiceSuccessResponse -> result.complete(response.result?.preimage)
|
||||
is PayInvoiceErrorResponse -> result.completeExceptionally(RuntimeException(response.error?.message ?: "Payment failed."))
|
||||
|
||||
+277
-60
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.napplet.gateways
|
||||
|
||||
import android.util.Base64
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResource
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletResourceResult
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.napplet.NappletNetworkRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Address
|
||||
@@ -38,10 +39,27 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver
|
||||
import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.sniffContentType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Authenticator
|
||||
import okhttp3.Call
|
||||
import okhttp3.Callback
|
||||
import okhttp3.CookieJar
|
||||
import okhttp3.Dns
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.net.InetAddress
|
||||
import java.net.URLDecoder
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.charset.CodingErrorAction
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Fetches a resource URL on an applet's behalf — the applet has no direct network
|
||||
@@ -63,41 +81,100 @@ class NappletResourceFetcher(
|
||||
private val account: Account,
|
||||
private val httpClient: (useProxy: Boolean) -> OkHttpClient,
|
||||
) {
|
||||
/** Fetches an https/data/blossom resource for the applet at [coordinate], or null if unsupported/unavailable. */
|
||||
/** Fetches an https/data/blossom/nostr resource and preserves the NAP-RESOURCE error category. */
|
||||
suspend fun fetch(
|
||||
url: String,
|
||||
coordinate: String,
|
||||
): NappletResource? =
|
||||
): NappletResourceResult =
|
||||
withContext(Dispatchers.IO) {
|
||||
// Route like the applet's own page: Tor when its network mode is Tor, clearnet otherwise.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = httpClient(NappletNetworkRegistry.useTor(coordinate))
|
||||
when {
|
||||
url.startsWith("data:") -> decodeDataUrl(url)
|
||||
url.startsWith("https://") -> {
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (!r.isSuccessful) return@withContext null
|
||||
val body = r.body.bytes()
|
||||
val type = r.header("Content-Type") ?: "application/octet-stream"
|
||||
NappletResource(body, type)
|
||||
}
|
||||
}.getOrNull()
|
||||
url.startsWith("nostr:") ->
|
||||
resolveNostr(url)?.let(::success) ?: failure(ERROR_NOT_FOUND, "Nostr resource not found.")
|
||||
url.startsWith("https://") || url.startsWith("blossom:") -> {
|
||||
// Route like the applet's own page: locked napplets stay on Tor. The derived
|
||||
// client removes ambient cookies/auth and validates DNS before every hop.
|
||||
NappletNetworkRegistry.awaitReady()
|
||||
val client = hardenedClient(httpClient(NappletNetworkRegistry.useTor(coordinate)))
|
||||
if (url.startsWith("https://")) fetchHttps(url, client) else fetchBlossom(url, client)
|
||||
}
|
||||
url.startsWith("blossom:") -> fetchBlossom(url, client)
|
||||
url.startsWith("nostr:") -> resolveNostr(url)
|
||||
else -> null
|
||||
else -> failure(ERROR_UNSUPPORTED_SCHEME, "Unsupported resource URL scheme.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun hardenedClient(baseClient: OkHttpClient): OkHttpClient =
|
||||
baseClient
|
||||
.newBuilder()
|
||||
.followRedirects(false)
|
||||
.followSslRedirects(false)
|
||||
.cache(null)
|
||||
.cookieJar(CookieJar.NO_COOKIES)
|
||||
.authenticator(Authenticator.NONE)
|
||||
.proxyAuthenticator(Authenticator.NONE)
|
||||
.callTimeout(FETCH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.dns(
|
||||
Dns { hostname ->
|
||||
baseClient.dns.lookup(hostname).also { addresses ->
|
||||
if (addresses.isEmpty() || !addresses.all(::isPublicAddress)) {
|
||||
throw BlockedResourceException("Resolved address is not public.")
|
||||
}
|
||||
}
|
||||
},
|
||||
).addNetworkInterceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.removeHeader("Authorization")
|
||||
.removeHeader("Cookie")
|
||||
.removeHeader("Proxy-Authorization")
|
||||
.build(),
|
||||
)
|
||||
}.build()
|
||||
|
||||
private suspend fun fetchHttps(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResourceResult {
|
||||
var current = safeHttpsUrl(url) ?: return failure(ERROR_BLOCKED, "Only credential-free HTTPS URLs are allowed.")
|
||||
repeat(MAX_REDIRECTS + 1) { hop ->
|
||||
try {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(current)
|
||||
.get()
|
||||
.build(),
|
||||
).await()
|
||||
.use { response ->
|
||||
if (response.isRedirect) {
|
||||
if (hop >= MAX_REDIRECTS) return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
val location = response.header("Location") ?: return failure(ERROR_NETWORK, "Redirect has no location.")
|
||||
current = safeHttpsUrl(current.resolve(location)) ?: return failure(ERROR_BLOCKED, "Redirect left credential-free HTTPS.")
|
||||
return@repeat
|
||||
}
|
||||
if (response.code == 404) return failure(ERROR_NOT_FOUND)
|
||||
if (!response.isSuccessful) return failure(ERROR_NETWORK, "Upstream returned HTTP ${response.code}.")
|
||||
if (response.body.contentLength() > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
val body = readBounded(response.body.byteStream()) ?: return failure(ERROR_TOO_LARGE)
|
||||
return classify(body)
|
||||
}
|
||||
} catch (e: BlockedResourceException) {
|
||||
return failure(ERROR_BLOCKED, e.message)
|
||||
} catch (_: InterruptedIOException) {
|
||||
return failure(ERROR_TIMEOUT)
|
||||
} catch (_: Exception) {
|
||||
return failure(ERROR_NETWORK)
|
||||
}
|
||||
}
|
||||
return failure(ERROR_BLOCKED, "Redirect limit exceeded.")
|
||||
}
|
||||
|
||||
private fun safeHttpsUrl(url: String): HttpUrl? = url.toHttpUrlOrNull()?.takeIf { isSafeHttpsResourceUrl(url) }
|
||||
|
||||
private fun safeHttpsUrl(url: HttpUrl?): HttpUrl? = url?.takeIf { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() }
|
||||
|
||||
/**
|
||||
* Resolves a `nostr:` URI (NIP-19) to the referenced event and returns its JSON. An `nembed`
|
||||
* carries the event inline; `note`/`nevent`/`naddr` resolve from the local cache, falling back to
|
||||
@@ -144,7 +221,7 @@ class NappletResourceFetcher(
|
||||
val relays = account.homeRelays.flow.value
|
||||
if (relays.isEmpty()) return null
|
||||
return runCatching {
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, timeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
account.client.fetchAll(filters = relays.associateWith { listOf(filter) }, idleTimeoutMs = NOSTR_FETCH_TIMEOUT_MS)
|
||||
}.getOrDefault(emptyList())
|
||||
.maxByOrNull { it.createdAt }
|
||||
}
|
||||
@@ -155,65 +232,205 @@ class NappletResourceFetcher(
|
||||
* wrong server can never substitute the blob. Returns null for a malformed hash or if no server
|
||||
* serves it.
|
||||
*/
|
||||
private fun fetchBlossom(
|
||||
private suspend fun fetchBlossom(
|
||||
url: String,
|
||||
client: OkHttpClient,
|
||||
): NappletResource? {
|
||||
val hash =
|
||||
url
|
||||
.removePrefix("blossom://")
|
||||
.removePrefix("blossom:")
|
||||
.substringBefore('/')
|
||||
.substringBefore('?')
|
||||
.trim()
|
||||
.lowercase()
|
||||
if (!hash.matches(Regex("^[0-9a-f]{64}$"))) return null
|
||||
): NappletResourceResult {
|
||||
if (!url.startsWith(BLOSSOM_SHA256_PREFIX)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
val hash = url.removePrefix(BLOSSOM_SHA256_PREFIX).lowercase()
|
||||
if (!hash.matches(SHA256)) return failure(ERROR_INVALID_REQUEST, "Malformed Blossom SHA-256 URL.")
|
||||
|
||||
val servers =
|
||||
account.blossomServers
|
||||
.getBlossomServersList()
|
||||
?.servers()
|
||||
.orEmpty()
|
||||
var sawHashMismatch = false
|
||||
for (candidate in StaticSiteResolver.candidateUrls(servers, hash)) {
|
||||
val bytes =
|
||||
runCatching {
|
||||
client
|
||||
.newCall(
|
||||
Request
|
||||
.Builder()
|
||||
.url(candidate)
|
||||
.get()
|
||||
.build(),
|
||||
).execute()
|
||||
.use { r ->
|
||||
if (r.isSuccessful) r.body.bytes() else null
|
||||
}
|
||||
}.getOrNull() ?: continue
|
||||
if (StaticSiteResolver.verify(bytes, hash)) {
|
||||
return NappletResource(bytes, sniffContentType(bytes) ?: "application/octet-stream")
|
||||
when (val fetched = fetchHttps(candidate, client)) {
|
||||
is NappletResourceResult.Success -> {
|
||||
if (!StaticSiteResolver.verify(fetched.resource.bytes, hash)) {
|
||||
sawHashMismatch = true
|
||||
continue
|
||||
}
|
||||
return fetched
|
||||
}
|
||||
is NappletResourceResult.Failure -> if (fetched.error == ERROR_BLOCKED) return fetched
|
||||
}
|
||||
}
|
||||
return null
|
||||
if (sawHashMismatch) return failure(ERROR_DECODE_FAILED, "Blossom SHA-256 verification failed.")
|
||||
return failure(ERROR_NOT_FOUND, "No Blossom server returned the verified blob.")
|
||||
}
|
||||
|
||||
private suspend fun Call.await(): Response =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
continuation.invokeOnCancellation { cancel() }
|
||||
enqueue(
|
||||
object : Callback {
|
||||
override fun onFailure(
|
||||
call: Call,
|
||||
e: IOException,
|
||||
) {
|
||||
if (continuation.isActive) continuation.resumeWith(Result.failure(e))
|
||||
}
|
||||
|
||||
override fun onResponse(
|
||||
call: Call,
|
||||
response: Response,
|
||||
) {
|
||||
if (continuation.isActive) {
|
||||
continuation.resumeWith(Result.success(response))
|
||||
} else {
|
||||
response.close()
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Parses a `data:[<mediatype>][;base64],<data>` URL into bytes + content type. */
|
||||
private fun decodeDataUrl(url: String): NappletResource? {
|
||||
private fun decodeDataUrl(url: String): NappletResourceResult {
|
||||
val comma = url.indexOf(',')
|
||||
if (comma < 0) return null
|
||||
if (comma < 0) return failure(ERROR_INVALID_REQUEST, "Malformed data URL.")
|
||||
val meta = url.substring("data:".length, comma)
|
||||
val data = url.substring(comma + 1)
|
||||
if (data.length > MAX_DATA_URL_CHARS) return failure(ERROR_TOO_LARGE)
|
||||
val isBase64 = meta.endsWith(";base64")
|
||||
val contentType = meta.removeSuffix(";base64").ifEmpty { "text/plain" }
|
||||
val declaredType =
|
||||
meta
|
||||
.removeSuffix(";base64")
|
||||
.substringBefore(';')
|
||||
.ifEmpty { "text/plain" }
|
||||
.lowercase()
|
||||
val bytes =
|
||||
if (isBase64) {
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull() ?: return null
|
||||
runCatching { Base64.decode(data, Base64.DEFAULT) }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid base64 data URL.")
|
||||
} else {
|
||||
URLDecoder.decode(data, "UTF-8").encodeToByteArray()
|
||||
runCatching { URLDecoder.decode(data, "UTF-8").encodeToByteArray() }.getOrNull()
|
||||
?: return failure(ERROR_DECODE_FAILED, "Invalid escaped data URL.")
|
||||
}
|
||||
return NappletResource(bytes, contentType)
|
||||
if (bytes.size > MAX_RESOURCE_BYTES) return failure(ERROR_TOO_LARGE)
|
||||
return classify(bytes, declaredType)
|
||||
}
|
||||
|
||||
private fun classify(
|
||||
bytes: ByteArray,
|
||||
declaredType: String? = null,
|
||||
): NappletResourceResult {
|
||||
if (looksLikeSvg(bytes)) return failure(ERROR_BLOCKED, "Raw SVG is not delivered by this runtime.")
|
||||
val sniffed = sniffContentType(bytes)
|
||||
val type =
|
||||
when {
|
||||
sniffed in ALLOWED_SNIFFED_TYPES -> sniffed
|
||||
declaredType == "application/json" && isJson(bytes) -> "application/json"
|
||||
declaredType == "text/plain" && isPlainText(bytes) -> "text/plain"
|
||||
else -> null
|
||||
} ?: return failure(ERROR_DECODE_FAILED, "Resource MIME is not in the runtime allowlist.")
|
||||
return success(NappletResource(bytes, type))
|
||||
}
|
||||
|
||||
private fun looksLikeSvg(bytes: ByteArray): Boolean {
|
||||
val prefix = bytes.copyOfRange(0, minOf(bytes.size, MIME_PREFIX_BYTES)).decodeToString().lowercase()
|
||||
return prefix.contains("<svg")
|
||||
}
|
||||
|
||||
private fun isJson(bytes: ByteArray): Boolean = runCatching { Json.parseToJsonElement(bytes.decodeToString()) }.isSuccess
|
||||
|
||||
private fun isPlainText(bytes: ByteArray): Boolean =
|
||||
runCatching {
|
||||
Charsets.UTF_8
|
||||
.newDecoder()
|
||||
.onMalformedInput(CodingErrorAction.REPORT)
|
||||
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
.decode(ByteBuffer.wrap(bytes))
|
||||
}.isSuccess && bytes.none { it == 0.toByte() }
|
||||
|
||||
private fun success(resource: NappletResource): NappletResourceResult = NappletResourceResult.Success(resource)
|
||||
|
||||
private fun failure(
|
||||
error: String,
|
||||
message: String? = null,
|
||||
): NappletResourceResult = NappletResourceResult.Failure(error, message)
|
||||
|
||||
private fun readBounded(input: java.io.InputStream): ByteArray? {
|
||||
input.use { source ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = source.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_RESOURCE_BYTES) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
return output.toByteArray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun isSafeHttpsResourceUrl(url: String): Boolean = url.toHttpUrlOrNull()?.let { it.scheme == "https" && it.username.isEmpty() && it.password.isEmpty() } == true
|
||||
|
||||
internal fun isPublicAddress(address: InetAddress): Boolean {
|
||||
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress || address.isSiteLocalAddress || address.isMulticastAddress) {
|
||||
return false
|
||||
}
|
||||
val bytes = address.address
|
||||
if (bytes.size == 4) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
val second = bytes[1].toInt() and 0xff
|
||||
// Shared address space (100.64/10) and reserved/non-routed ranges Java does not classify.
|
||||
if (first == 0 || first >= 224) return false
|
||||
if (first == 100 && second in 64..127) return false
|
||||
if (first == 192 && second == 0) return false
|
||||
if (first == 198 && second in 18..19) return false
|
||||
if (first == 198 && second == 51 && (bytes[2].toInt() and 0xff) == 100) return false
|
||||
if (first == 203 && second == 0 && (bytes[2].toInt() and 0xff) == 113) return false
|
||||
} else if (bytes.size == 16) {
|
||||
val first = bytes[0].toInt() and 0xff
|
||||
if (first and 0xfe == 0xfc) return false // fc00::/7 unique-local
|
||||
if (
|
||||
first == 0x20 &&
|
||||
(bytes[1].toInt() and 0xff) == 0x01 &&
|
||||
(bytes[2].toInt() and 0xff) == 0x0d &&
|
||||
(bytes[3].toInt() and 0xff) == 0xb8
|
||||
) {
|
||||
return false // 2001:db8::/32 documentation range
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private const val NOSTR_FETCH_TIMEOUT_MS = 8_000L
|
||||
private const val FETCH_TIMEOUT_SECONDS = 30L
|
||||
private const val MAX_REDIRECTS = 5
|
||||
private const val MIME_PREFIX_BYTES = 8 * 1024
|
||||
private const val MAX_DATA_URL_CHARS = 24 * 1024 * 1024
|
||||
private const val BLOSSOM_SHA256_PREFIX = "blossom:sha256:"
|
||||
const val MAX_RESOURCE_BYTES = 10 * 1024 * 1024
|
||||
private const val ERROR_INVALID_REQUEST = "invalid-request"
|
||||
private const val ERROR_NOT_FOUND = "not-found"
|
||||
private const val ERROR_BLOCKED = "blocked-by-policy"
|
||||
private const val ERROR_TIMEOUT = "timeout"
|
||||
private const val ERROR_TOO_LARGE = "too-large"
|
||||
private const val ERROR_UNSUPPORTED_SCHEME = "unsupported-scheme"
|
||||
private const val ERROR_DECODE_FAILED = "decode-failed"
|
||||
private const val ERROR_NETWORK = "network-error"
|
||||
private val SHA256 = Regex("^[0-9a-f]{64}$")
|
||||
private val ALLOWED_SNIFFED_TYPES =
|
||||
setOf(
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/bmp",
|
||||
"audio/ogg",
|
||||
"video/mp4",
|
||||
)
|
||||
}
|
||||
|
||||
private class BlockedResourceException(
|
||||
message: String,
|
||||
) : java.io.IOException(message)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ object ClinkDebitPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -85,7 +85,7 @@ object ClinkOfferPayer {
|
||||
|
||||
val listener =
|
||||
object : SubscriptionListener {
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
|
||||
@@ -159,7 +159,7 @@ class V4VPaymentHandler(
|
||||
tlvRecords = tlvRecords,
|
||||
)
|
||||
|
||||
account.sendNwcRequest(request) { response: Response? ->
|
||||
account.zaps.sendNwcRequest(request) { response: Response? ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
@@ -195,7 +195,7 @@ class V4VPaymentHandler(
|
||||
try {
|
||||
val nostrRequest =
|
||||
if (asZap && noteEvent != null) {
|
||||
account.createZapRequestFor(
|
||||
account.zaps.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = null,
|
||||
message = message,
|
||||
@@ -250,7 +250,7 @@ class V4VPaymentHandler(
|
||||
is PaymentSource.Nwc -> {
|
||||
var done = 0
|
||||
payables.forEach { payable ->
|
||||
account.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
account.zaps.sendZapPaymentRequestFor(payable.invoice, zappedNote) { response ->
|
||||
if (response is IErrorResponseLike) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
|
||||
@@ -163,7 +163,7 @@ class ZapPaymentHandler(
|
||||
val canBolt12 =
|
||||
account.settings.nwcWallets.value
|
||||
.isNotEmpty() &&
|
||||
account.defaultWalletSupportsBolt12Pay()
|
||||
account.zaps.defaultWalletSupportsBolt12Pay()
|
||||
|
||||
val bolt12Recipients =
|
||||
unverifiedZapsToSend.mapNotNull {
|
||||
@@ -330,7 +330,7 @@ class ZapPaymentHandler(
|
||||
|
||||
val zapRequest =
|
||||
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) {
|
||||
account.createZapRequestFor(
|
||||
account.zaps.createZapRequestFor(
|
||||
event = noteEvent,
|
||||
pollOption = pollOption,
|
||||
message = message,
|
||||
@@ -414,7 +414,7 @@ class ZapPaymentHandler(
|
||||
return mapNotNullAsync(
|
||||
items = payables,
|
||||
runRequestFor = { payable: Payable ->
|
||||
account.sendZapPaymentRequestFor(
|
||||
account.zaps.sendZapPaymentRequestFor(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = note,
|
||||
onResponse = { response ->
|
||||
@@ -462,7 +462,7 @@ class ZapPaymentHandler(
|
||||
val progress = PaymentProgress(recipients.size, onProgress)
|
||||
|
||||
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
|
||||
account.sendBolt12Zap(
|
||||
account.zaps.sendBolt12Zap(
|
||||
zappedEvent = note.event,
|
||||
recipientPubKey = recipient.user.pubkeyHex,
|
||||
offer = recipient.offer,
|
||||
|
||||
+8
-8
@@ -54,21 +54,21 @@ class MemoryTrimmingService(
|
||||
) {
|
||||
// Tier 1: always run — cheap housekeeping; cleanObservers only removes flows that are
|
||||
// not currently held by the UI, so it is safe and inexpensive at any pressure level.
|
||||
cache.cleanMemory()
|
||||
cache.cleanObservers()
|
||||
cache.pruneExpiredEvents()
|
||||
cache.prunePastVersionsOfReplaceables()
|
||||
cache.pruner.cleanMemory()
|
||||
cache.pruner.cleanObservers()
|
||||
cache.pruner.pruneExpiredEvents()
|
||||
cache.pruner.prunePastVersionsOfReplaceables()
|
||||
|
||||
if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
|
||||
// Tier 2: real reclaim pressure — drop events from muted/blocked users, old
|
||||
// messages, and unobserved reactions.
|
||||
account.forEach {
|
||||
cache.pruneHiddenEvents(it)
|
||||
cache.pruneHiddenMessages(it)
|
||||
cache.pruner.pruneHiddenEvents(it)
|
||||
cache.pruner.pruneHiddenMessages(it)
|
||||
}
|
||||
val accounts = otherAccounts.mapNotNull { decodePublicKeyAsHexOrNull(it.npub) }.toSet()
|
||||
cache.pruneOldMessages()
|
||||
cache.pruneRepliesAndReactions(accounts)
|
||||
cache.pruner.pruneOldMessages()
|
||||
cache.pruner.pruneRepliesAndReactions(accounts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+107
-26
@@ -21,56 +21,137 @@
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_DISTANCE
|
||||
import com.vitorpamplona.amethyst.service.location.LocationState.Companion.MIN_TIME
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Wraps [LocationManager] update registration as a cold [Flow].
|
||||
*
|
||||
* Registers on **one** provider, chosen by [LocationProviderLadder], rather than
|
||||
* on every provider the device reports. The previous shotgun cost four
|
||||
* simultaneous registrations — passive, network, fused and gps, the last at
|
||||
* HIGH_ACCURACY — to produce a 5 km geohash.
|
||||
*
|
||||
* Takes a [LocationManager] rather than a `Context` so the registration
|
||||
* behaviour is unit-testable; the caller does the `getSystemService` lookup.
|
||||
*
|
||||
* [onListening] is fired from inside the flow, after a registration succeeds, and
|
||||
* released again from the `try`/`finally` that wraps everything after it, never
|
||||
* as an `onStart`/`onCompletion` pair on the returned flow. The distinction
|
||||
* matters: an `onStart` fires on collection even when nothing registered, so a
|
||||
* device with no usable provider would accrue location time with no location
|
||||
* running, and — because the ledger refcounts the two [LocationState] flows
|
||||
* together — the unpaired close would steal the other flow's holder.
|
||||
*
|
||||
* The pair is kept honest from both ends. The acquire cannot fire without a
|
||||
* registration, because a failure to register throws before reaching it. The
|
||||
* release cannot be skipped, because everything after the acquire runs inside a
|
||||
* `try`/`finally` rather than inside `awaitClose` — [freshestLastKnownLocation]
|
||||
* (the seed sweep below) can throw a non-cancellation exception and unwind
|
||||
* before `awaitClose` is ever reached, and `try`/`finally` is what still runs
|
||||
* the release on that path; see `releasesTheRegistrationWhenTheSeedThrows` in
|
||||
* `LocationFlowTest`. (In principle a collector cancelling mid-seed would also
|
||||
* unwind past `awaitClose` the same way, but that path could not be
|
||||
* reproduced — `callbackFlow`'s buffer is empty at this point, so the single
|
||||
* seed send returns without suspending and never observes the cancellation.)
|
||||
*/
|
||||
class LocationFlow(
|
||||
private val context: Context,
|
||||
private val locationManager: LocationManager,
|
||||
private val sdkInt: Int = Build.VERSION.SDK_INT,
|
||||
) {
|
||||
@SuppressLint("MissingPermission")
|
||||
fun get(
|
||||
minTimeMs: Long = MIN_TIME,
|
||||
minDistanceM: Float = MIN_DISTANCE,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
onListening: ((Boolean) -> Unit)? = null,
|
||||
): Flow<Location> =
|
||||
callbackFlow {
|
||||
Log.i("LocationFlow", "Start")
|
||||
val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager
|
||||
|
||||
val locationCallback =
|
||||
LocationListener { location ->
|
||||
Log.d("LocationFlow") { "onLocationChanged $location" }
|
||||
launch { send(location) }
|
||||
}
|
||||
|
||||
locationManager.allProviders.forEach {
|
||||
val location = locationManager.getLastKnownLocation(it)
|
||||
Log.d("LocationFlow") { "Last Known location is $location" }
|
||||
if (location != null) {
|
||||
send(location)
|
||||
}
|
||||
Log.d("LocationFlow", "Requesting Updates")
|
||||
locationManager.requestLocationUpdates(
|
||||
it,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationCallback,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
}
|
||||
// One binder call, reused for both the ladder filter and the seed.
|
||||
val providers = locationManager.allProviders
|
||||
|
||||
awaitClose {
|
||||
Log.i("LocationFlow", "Stop")
|
||||
val candidates = LocationProviderLadder.chooseProviders(sdkInt) { it in providers }
|
||||
|
||||
val registered =
|
||||
candidates.firstOrNull { requestUpdates(it, minTimeMs, minDistanceM, locationCallback) }
|
||||
?: throw SecurityException("No usable location provider. Candidates: $candidates")
|
||||
|
||||
Log.i("LocationFlow") { "Listening on $registered every ${minTimeMs}ms / ${minDistanceM}m" }
|
||||
onListening?.invoke(true)
|
||||
|
||||
// Cleanup lives in this finally, not in awaitClose — see the class
|
||||
// KDoc, and `releasesTheRegistrationWhenTheSeedThrows` in
|
||||
// LocationFlowTest, which fails if it moves.
|
||||
try {
|
||||
// Seeded after registration so the no-provider path throws
|
||||
// without having emitted anything; seeding first would show the
|
||||
// consumer Success -> LackPermission on a device with no
|
||||
// compatible provider.
|
||||
freshestLastKnownLocation(candidates)?.let {
|
||||
Log.d("LocationFlow") { "Last known location is $it" }
|
||||
send(it)
|
||||
}
|
||||
|
||||
awaitClose { }
|
||||
} finally {
|
||||
Log.i("LocationFlow") { "Stopped listening on $registered" }
|
||||
locationManager.removeUpdates(locationCallback)
|
||||
onListening?.invoke(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the registration was accepted; false when the provider refused it. */
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun requestUpdates(
|
||||
provider: String,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
locationCallback: LocationListener,
|
||||
): Boolean =
|
||||
try {
|
||||
locationManager.requestLocationUpdates(
|
||||
provider,
|
||||
minTimeMs,
|
||||
minDistanceM,
|
||||
locationCallback,
|
||||
Looper.getMainLooper(),
|
||||
)
|
||||
true
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("LocationFlow", "Provider $provider refused the update request", e)
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* The freshest cached fix across the providers the ladder deemed usable.
|
||||
* Sweeping every provider the device reports instead would, on the
|
||||
* coarse-only legacy path, pay a guaranteed-to-throw binder call per
|
||||
* fine-only provider on every flow start. Still guarded per provider, like
|
||||
* the update request is — on a device where one refuses us, the others
|
||||
* should still seed.
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
private fun freshestLastKnownLocation(providers: List<String>): Location? =
|
||||
providers
|
||||
.mapNotNull { provider ->
|
||||
try {
|
||||
locationManager.getLastKnownLocation(provider)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("LocationFlow", "No permission to read the last known location of $provider", e)
|
||||
null
|
||||
}
|
||||
}.maxByOrNull { it.time }
|
||||
}
|
||||
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
|
||||
/**
|
||||
* Picks which location providers to try, in order.
|
||||
*
|
||||
* Deliberately selects on **provider existence**, never on
|
||||
* [LocationManager.isProviderEnabled]. A registration on a disabled provider
|
||||
* goes live by itself when the user enables location — including from the
|
||||
* quick-settings shade without leaving the app, which is exactly what someone
|
||||
* does after seeing an empty "Around Me" feed. An enabled-state guard evaluated
|
||||
* once at subscription start would lose that.
|
||||
*
|
||||
* Below API 31, `gps`, `passive` and `fused` required `ACCESS_FINE_LOCATION`;
|
||||
* only `network` accepted `ACCESS_COARSE_LOCATION`. Approximate location, which
|
||||
* lets a coarse-only app request any provider and receive a fuzzed result, is an
|
||||
* Android 12 change. Amethyst declares coarse only, so the legacy branch is
|
||||
* unconditional below API 31.
|
||||
*
|
||||
* Adding `ACCESS_FINE_LOCATION` later would **not** widen this on its own — the
|
||||
* branch below has no permission input, so pre-31 devices would keep getting
|
||||
* `network` alone and silently lose the precision the new permission was granted
|
||||
* for. Whoever adds it must widen the condition here too.
|
||||
*
|
||||
* Returns the ordered candidate list rather than a single choice so the caller
|
||||
* can fall through to the next rung if a registration is refused. An empty list
|
||||
* means no compatible provider exists.
|
||||
*/
|
||||
object LocationProviderLadder {
|
||||
// Compile-time String constants, inlined by the compiler, so naming
|
||||
// FUSED_PROVIDER (added in API 31) is safe on older runtimes.
|
||||
private val FULL_LADDER =
|
||||
listOf(
|
||||
LocationManager.FUSED_PROVIDER,
|
||||
LocationManager.NETWORK_PROVIDER,
|
||||
LocationManager.GPS_PROVIDER,
|
||||
LocationManager.PASSIVE_PROVIDER,
|
||||
)
|
||||
|
||||
private val COARSE_ONLY_LEGACY_LADDER = listOf(LocationManager.NETWORK_PROVIDER)
|
||||
|
||||
fun chooseProviders(
|
||||
sdkInt: Int,
|
||||
exists: (String) -> Boolean,
|
||||
): List<String> {
|
||||
val ladder = if (sdkInt >= Build.VERSION_CODES.S) FULL_LADDER else COARSE_ONLY_LEGACY_LADDER
|
||||
|
||||
return ladder.filter(exists)
|
||||
}
|
||||
}
|
||||
+162
-65
@@ -21,32 +21,83 @@
|
||||
package com.vitorpamplona.amethyst.service.location
|
||||
|
||||
import android.content.Context
|
||||
import android.location.Location
|
||||
import android.location.LocationManager
|
||||
import com.vitorpamplona.quartz.experimental.bitchat.geohash.GeohashChannelLevel
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeoHash
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.GeohashPrecision
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.emitAll
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.transformLatest
|
||||
|
||||
// `toGeoHash` is an extension on Location declared in LocationGeoHash.kt, same
|
||||
// package, so it needs no import.
|
||||
|
||||
/**
|
||||
* Turns the device's location into geohashes, listening **only while the app is
|
||||
* in the foreground**.
|
||||
*
|
||||
* The gate is not an optimisation of last resort: `Account` builds 30
|
||||
* `SharingStarted.Eagerly` top-nav filter states on the account scope, and
|
||||
* `AccountSettings.defaultProductsFollowList` ships as `TopFilter.AroundMe`, so
|
||||
* without it every user with location permission holds a registration for the
|
||||
* life of the process. See `amethyst/plans/2026-07-29-location-foreground-gate.md`.
|
||||
*
|
||||
* Switching the *consumers* to `WhileSubscribed` is not an option: roughly 60
|
||||
* call sites read `account.live*FollowLists.value` synchronously rather than
|
||||
* collecting, and would silently serve a stale or initial value.
|
||||
*/
|
||||
class LocationState(
|
||||
context: Context,
|
||||
scope: CoroutineScope,
|
||||
/** Resource-ledger hook: true while GPS/location updates are actively requested. */
|
||||
private val scope: CoroutineScope,
|
||||
private val isForeground: StateFlow<Boolean>,
|
||||
/**
|
||||
* Resource-ledger hook: true while location updates are actively
|
||||
* registered. Reaches the OS only through the default [locationSource],
|
||||
* which hands it to [LocationFlow] — a caller that overrides
|
||||
* [locationSource] (the tests do) is responsible for firing it, or not.
|
||||
*/
|
||||
private val onListening: ((Boolean) -> Unit)? = null,
|
||||
private val locationSource: (Long, Float) -> Flow<Location> = { minTimeMs, minDistanceM ->
|
||||
LocationFlow(context.getSystemService(Context.LOCATION_SERVICE) as LocationManager)
|
||||
.get(minTimeMs, minDistanceM, onListening)
|
||||
},
|
||||
) {
|
||||
companion object {
|
||||
const val MIN_TIME: Long = 10000L
|
||||
const val MIN_DISTANCE: Float = 100.0f
|
||||
/** A 5 km cell takes 2.5 minutes to cross at 120 km/h; 60s/500m is ample. */
|
||||
const val COARSE_MIN_TIME: Long = 60_000L
|
||||
const val COARSE_MIN_DISTANCE: Float = 500.0f
|
||||
|
||||
/** Building-level geohashes need the tighter profile. */
|
||||
const val PRECISE_MIN_TIME: Long = 10_000L
|
||||
const val PRECISE_MIN_DISTANCE: Float = 100.0f
|
||||
|
||||
/**
|
||||
* How long to keep listening after the last activity stops, so a
|
||||
* one-second app switch doesn't destroy and rebuild the registration.
|
||||
* Same intent as [SUBSCRIPTION_STOP_TIMEOUT_MS], on the other axis.
|
||||
*/
|
||||
const val BACKGROUND_GRACE_MS: Long = 5_000L
|
||||
|
||||
/**
|
||||
* How long `stateIn` keeps the upstream alive after the last collector
|
||||
* leaves, so a screen rotation or a tab switch doesn't rebuild the
|
||||
* registration either.
|
||||
*/
|
||||
const val SUBSCRIPTION_STOP_TIMEOUT_MS: Long = 5_000L
|
||||
}
|
||||
|
||||
sealed class LocationResult {
|
||||
@@ -59,9 +110,16 @@ class LocationState(
|
||||
object Loading : LocationResult()
|
||||
}
|
||||
|
||||
private enum class Gate { NoPermission, Paused, Listen }
|
||||
|
||||
private var hasLocationPermission = MutableStateFlow(false)
|
||||
private var latestLocation: LocationResult = LocationResult.Loading
|
||||
private var latestPreciseLocation: LocationResult = LocationResult.Loading
|
||||
|
||||
// Read by R1 below to decide whether to emit Loading, from a different
|
||||
// coroutine than the onEach that writes it — hence a StateFlow rather than
|
||||
// a plain field.
|
||||
private val latestLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
|
||||
|
||||
private val latestPreciseLocation = MutableStateFlow<LocationResult>(LocationResult.Loading)
|
||||
|
||||
fun setLocationPermission(newValue: Boolean) {
|
||||
if (newValue != hasLocationPermission.value) {
|
||||
@@ -69,36 +127,92 @@ class LocationState(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Foreground with an asymmetric delay: leaving the foreground waits out
|
||||
* [BACKGROUND_GRACE_MS], returning to it is immediate.
|
||||
*
|
||||
* `debounce(5000)` would delay both edges, and the duration-selector
|
||||
* overload that allows an asymmetric delay is `@FlowPreview`.
|
||||
* `transformLatest` cancels the pending `delay` when foreground returns
|
||||
* first, which is exactly the semantics wanted, with no preview opt-in.
|
||||
*
|
||||
* Known and harmless: [ForegroundTracker] starts at `false`, so on a
|
||||
* process that starts backgrounded the first emission — and therefore the
|
||||
* first gate verdict, including `LackPermission` — is delayed by
|
||||
* [BACKGROUND_GRACE_MS]. Nothing renders while backgrounded, and a process
|
||||
* that starts into the foreground emits immediately, because the activity's
|
||||
* `onStart` cancels the pending delay.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val geohashStateFlow by lazy {
|
||||
hasLocationPermission
|
||||
.transformLatest {
|
||||
if (it) {
|
||||
emit(LocationResult.Loading)
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashPrecision.KM_5_X_5.digits)) as LocationResult
|
||||
}.onEach {
|
||||
latestLocation = it
|
||||
}.catch { e ->
|
||||
Log.w("GeohashStateFlow", "Exception in the flow", e)
|
||||
latestLocation = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
private val settledForeground: Flow<Boolean> =
|
||||
isForeground.transformLatest { foreground ->
|
||||
if (!foreground) delay(BACKGROUND_GRACE_MS)
|
||||
emit(foreground)
|
||||
}
|
||||
|
||||
emitAll(result)
|
||||
} else {
|
||||
emit(LocationResult.LackPermission)
|
||||
private val gate: Flow<Gate> =
|
||||
combine(hasLocationPermission, settledForeground) { permitted, foreground ->
|
||||
when {
|
||||
!permitted -> Gate.NoPermission
|
||||
foreground -> Gate.Listen
|
||||
else -> Gate.Paused
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun buildGeohashStateFlow(
|
||||
tag: String,
|
||||
charsCount: Int,
|
||||
minTimeMs: Long,
|
||||
minDistanceM: Float,
|
||||
cache: MutableStateFlow<LocationResult>,
|
||||
): StateFlow<LocationResult> =
|
||||
gate
|
||||
.transformLatest { state ->
|
||||
when (state) {
|
||||
// Deliberately does NOT write to the cache. Today's code emits
|
||||
// LackPermission without touching the cache, and wiping it
|
||||
// here would cost a Loading emission — and so an empty-feed
|
||||
// flash — on every permission flap, which is the regression R1
|
||||
// exists to prevent. Consumers already see LackPermission from
|
||||
// the StateFlow; the cache is internal and only decides whether
|
||||
// Loading is emitted.
|
||||
Gate.NoPermission -> emit(LocationResult.LackPermission)
|
||||
|
||||
// Emit nothing: stateIn keeps the last value, so every
|
||||
// synchronous .value reader still sees the last known geohash
|
||||
// while the OS registration is released.
|
||||
Gate.Paused -> Unit
|
||||
|
||||
Gate.Listen -> {
|
||||
// Only when there is nothing cached. Emitting Loading on
|
||||
// every foreground return would flash the "Around Me" feed
|
||||
// empty, because AroundMeFeedFlow.convert maps anything
|
||||
// that is not Success to an empty geotag set.
|
||||
if (cache.value !is LocationResult.Success) emit(LocationResult.Loading)
|
||||
|
||||
emitAll(
|
||||
locationSource(minTimeMs, minDistanceM)
|
||||
.map { LocationResult.Success(it.toGeoHash(charsCount)) as LocationResult }
|
||||
.onEach { cache.value = it }
|
||||
.catch { e ->
|
||||
Log.w(tag, "Exception in the flow", e)
|
||||
cache.value = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
latestLocation,
|
||||
)
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(SUBSCRIPTION_STOP_TIMEOUT_MS), cache.value)
|
||||
|
||||
val geohashStateFlow: StateFlow<LocationResult> by lazy {
|
||||
buildGeohashStateFlow(
|
||||
tag = "GeohashStateFlow",
|
||||
charsCount = GeohashPrecision.KM_5_X_5.digits,
|
||||
minTimeMs = COARSE_MIN_TIME,
|
||||
minDistanceM = COARSE_MIN_DISTANCE,
|
||||
cache = latestLocation,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,36 +221,19 @@ class LocationState(
|
||||
* to every coarser level (a geohash is a prefix code), so one fix yields the
|
||||
* whole region→building ladder. Kept separate so the coarser
|
||||
* [geohashStateFlow] the "around me" feed relies on is unchanged.
|
||||
*
|
||||
* Note that Amethyst declares only `ACCESS_COARSE_LOCATION`, so Android
|
||||
* fuzzes every fix to roughly a 3 km grid and this is not in fact
|
||||
* building-level today. The profile is kept so the intent survives if the
|
||||
* app ever requests `ACCESS_FINE_LOCATION`.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
val preciseGeohashStateFlow by lazy {
|
||||
hasLocationPermission
|
||||
.transformLatest {
|
||||
if (it) {
|
||||
emit(LocationResult.Loading)
|
||||
val result =
|
||||
LocationFlow(context)
|
||||
.get(MIN_TIME, MIN_DISTANCE)
|
||||
.onStart { onListening?.invoke(true) }
|
||||
.onCompletion { onListening?.invoke(false) }
|
||||
.map {
|
||||
LocationResult.Success(it.toGeoHash(GeohashChannelLevel.BUILDING.chars)) as LocationResult
|
||||
}.onEach {
|
||||
latestPreciseLocation = it
|
||||
}.catch { e ->
|
||||
Log.w("GeohashStateFlow", "Exception in the precise flow", e)
|
||||
latestPreciseLocation = LocationResult.LackPermission
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
|
||||
emitAll(result)
|
||||
} else {
|
||||
emit(LocationResult.LackPermission)
|
||||
}
|
||||
}.stateIn(
|
||||
scope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
latestPreciseLocation,
|
||||
)
|
||||
val preciseGeohashStateFlow: StateFlow<LocationResult> by lazy {
|
||||
buildGeohashStateFlow(
|
||||
tag = "PreciseGeohashStateFlow",
|
||||
charsCount = GeohashChannelLevel.BUILDING.chars,
|
||||
minTimeMs = PRECISE_MIN_TIME,
|
||||
minDistanceM = PRECISE_MIN_DISTANCE,
|
||||
cache = latestPreciseLocation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+108
-56
@@ -22,13 +22,17 @@ package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountSubscriptionRegistry
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
@@ -45,31 +49,45 @@ import kotlinx.coroutines.launch
|
||||
* L4 - BootCompletedReceiver (restart on boot)
|
||||
* L5 - ServiceWatchdogManager (AlarmManager, 5-min health check)
|
||||
*
|
||||
* Two switches gate the system:
|
||||
* It also decides **which accounts pull from relays**, which is a different question from
|
||||
* whether the service runs, and the two are deliberately not gated the same way.
|
||||
*
|
||||
* - The **global master** ([LocalPreferences.notificationServiceEnabledFlow], the
|
||||
* "Background notification service" toggle / Quick Settings tile). When off, every
|
||||
* layer is torn down and nothing restarts, regardless of any account's setting —
|
||||
* this is the battery-saver "airplane mode". Persisted, so an explicit off survives
|
||||
* restarts and crashes.
|
||||
* - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService],
|
||||
* "Keep this account active in the background") **or** its NIP-46 signer toggle
|
||||
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is
|
||||
* on, the service runs as long as **at least one** writable account has either flag on — the
|
||||
* background signer relies on the same foreground service to keep answering requests.
|
||||
* ## Who subscribes
|
||||
*
|
||||
* While the master is on, every saved writable account is kept loaded in
|
||||
* [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps
|
||||
* addressed to any of them (delivered via open relay subscriptions) get unwrapped by
|
||||
* the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
|
||||
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no
|
||||
* subscriber able to decrypt them.
|
||||
* - **While a screen is up: every loaded account.** The user can switch accounts at any moment
|
||||
* and expects the one they land on to be current, so all of them keep their own notifications,
|
||||
* DMs and gift wraps live. This costs nothing once the app is away — it ends with the screen.
|
||||
* - **While the app is away: only the accounts that opted in**, via
|
||||
* [com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService] ("Keep this
|
||||
* account active in the background") or their NIP-46 signer toggle
|
||||
* ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]).
|
||||
*
|
||||
* That is what the setting's name promises, and for a while it did not hold: participation gated
|
||||
* subscriptions everywhere, so an account you had not opted in for showed no notifications even
|
||||
* with the app open in front of you.
|
||||
*
|
||||
* ## Whether the service runs
|
||||
*
|
||||
* The five layers are a background concern, so they stay gated on **both** the global master
|
||||
* ([LocalPreferences.notificationServiceEnabledFlow], the "Background notification service"
|
||||
* toggle / Quick Settings tile — the battery-saver "airplane mode", persisted so an explicit off
|
||||
* survives restarts) **and** at least one account having opted in. A foreground-only account must
|
||||
* never start a foreground service that outlives the screen that wanted it.
|
||||
*
|
||||
* Every saved writable account is kept loaded in [AccountCacheState] whenever either condition
|
||||
* holds, so (a) participation flags are observable and (b) GiftWraps addressed to any of them get
|
||||
* unwrapped by the owning account's `newNotesPreProcessor`. Without this, wraps for non-active
|
||||
* accounts would sit in [com.vitorpamplona.amethyst.model.LocalCache] with no subscriber able to
|
||||
* decrypt them.
|
||||
*/
|
||||
class AlwaysOnNotificationServiceManager(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val accountsCache: AccountCacheState,
|
||||
private val localPreferences: LocalPreferences,
|
||||
private val subscriptions: AccountSubscriptionRegistry,
|
||||
/** True while any activity is STARTED — see [com.vitorpamplona.amethyst.service.resourceusage.ForegroundTracker]. */
|
||||
private val isForeground: StateFlow<Boolean>,
|
||||
private val activePubKeyProvider: () -> HexKey?,
|
||||
) {
|
||||
companion object {
|
||||
@@ -98,55 +116,84 @@ class AlwaysOnNotificationServiceManager(
|
||||
wasEnabled = false
|
||||
watchJob =
|
||||
scope.launch {
|
||||
localPreferences.notificationServiceEnabledFlow().collectLatest { masterEnabled ->
|
||||
if (!masterEnabled) {
|
||||
// Global airplane mode: suppress every layer regardless of
|
||||
// per-account participation, and stop keeping accounts loaded.
|
||||
if (wasEnabled) {
|
||||
disableServiceLayers()
|
||||
wasEnabled = false
|
||||
}
|
||||
stopMultiAccountPreload()
|
||||
return@collectLatest
|
||||
}
|
||||
|
||||
// Master on: keep every writable account loaded so its participation
|
||||
// flag is observable and its gift wraps can decrypt, then run the
|
||||
// service only while at least one account is participating. An account
|
||||
// participates when its always-on setting OR its NIP-46 signer toggle is
|
||||
// on — the background signer needs the same foreground service alive.
|
||||
startMultiAccountPreload()
|
||||
accountsCache.accounts
|
||||
.flatMapLatest { accounts ->
|
||||
val flags =
|
||||
accounts.values.map { account ->
|
||||
account.settings.alwaysOnNotificationService
|
||||
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer }
|
||||
}
|
||||
if (flags.isEmpty()) {
|
||||
flowOf(false)
|
||||
} else {
|
||||
combine(flags) { values -> values.any { it } }
|
||||
}
|
||||
}.distinctUntilChanged()
|
||||
.collectLatest { anyParticipating ->
|
||||
if (anyParticipating) {
|
||||
wasEnabled = true
|
||||
enableServiceLayers()
|
||||
} else if (wasEnabled) {
|
||||
localPreferences
|
||||
.notificationServiceEnabledFlow()
|
||||
.combine(isForeground) { masterEnabled, foreground -> masterEnabled to foreground }
|
||||
.collectLatest { (masterEnabled, foreground) ->
|
||||
if (!masterEnabled && !foreground) {
|
||||
// Nothing wants the accounts: the master is off and no screen is up.
|
||||
// Suppress every layer and stop keeping accounts loaded.
|
||||
if (wasEnabled) {
|
||||
disableServiceLayers()
|
||||
wasEnabled = false
|
||||
}
|
||||
stopMultiAccountPreload()
|
||||
return@collectLatest
|
||||
}
|
||||
}
|
||||
|
||||
// Keep every writable account loaded — in the foreground so they can all
|
||||
// pull, and with the master on so participation flags are observable and
|
||||
// gift wraps can decrypt.
|
||||
startMultiAccountPreload()
|
||||
|
||||
accountsAndParticipants()
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { (all, participating) ->
|
||||
// The rule the "keep this account active in the background" setting
|
||||
// actually describes: while a screen is up, EVERY loaded account
|
||||
// pulls its own notifications, DMs and gift wraps, because the user
|
||||
// can switch to any of them and expects them current. The setting
|
||||
// only decides which ones keep doing it once the app is away.
|
||||
subscriptions.sync(if (foreground) all else participating)
|
||||
|
||||
// The service layers are a background concern, so they stay tied to
|
||||
// the master switch and to somebody having opted in. A foreground-only
|
||||
// account must not start a foreground service that outlives the screen.
|
||||
//
|
||||
// Edge-triggered, deliberately. This flow re-emits whenever the account
|
||||
// map changes identity or the app crosses foreground — far more often
|
||||
// than the old boolean did — and ServiceWatchdogManager.schedule()
|
||||
// replaces its alarm with one starting `now + 5min`. Calling it on every
|
||||
// emission pushed the watchdog's first fire past every screen-on, so the
|
||||
// layer that exists to restart a dead service would never have run.
|
||||
val shouldRun = masterEnabled && participating.isNotEmpty()
|
||||
if (shouldRun != wasEnabled) {
|
||||
if (shouldRun) enableServiceLayers() else disableServiceLayers()
|
||||
wasEnabled = shouldRun
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every loaded account paired with the subset that opted into running in the background.
|
||||
*
|
||||
* Both come from one flow because they change together and the two decisions below — who
|
||||
* subscribes, and whether the service runs — must never be made from different snapshots.
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun accountsAndParticipants(): Flow<Pair<List<Account>, List<Account>>> =
|
||||
accountsCache.accounts.flatMapLatest { accounts ->
|
||||
val all = accounts.values.toList()
|
||||
val flags =
|
||||
all.map { account ->
|
||||
account.settings.alwaysOnNotificationService
|
||||
.combine(account.settings.nip46SignerEnabled) { alwaysOn, signer ->
|
||||
if (alwaysOn || signer) account else null
|
||||
}
|
||||
}
|
||||
if (flags.isEmpty()) {
|
||||
flowOf(all to emptyList())
|
||||
} else {
|
||||
combine(flags) { values -> all to values.filterNotNull() }
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
watchJob?.cancel()
|
||||
watchJob = null
|
||||
preloadJob?.cancel()
|
||||
preloadJob = null
|
||||
stopMultiAccountPreload()
|
||||
// Logout/terminate: tear the layers down explicitly. Otherwise the watchdog alarm
|
||||
// and periodic worker stay scheduled and would resurrect the service for a
|
||||
// logged-out user (nobody participating).
|
||||
@@ -211,10 +258,15 @@ class AlwaysOnNotificationServiceManager(
|
||||
* Cancels the preload collector and releases every cached account except the
|
||||
* currently active one, so users with the master off return to single-account
|
||||
* memory/battery footprint.
|
||||
*
|
||||
* Unmounts the background subscriptions too: with the master off, the only
|
||||
* account that should be talking to relays is the one on screen, and its
|
||||
* subscription comes from the screen's own mount.
|
||||
*/
|
||||
private fun stopMultiAccountPreload() {
|
||||
preloadJob?.cancel()
|
||||
preloadJob = null
|
||||
subscriptions.clear()
|
||||
// remove this because we don't know which other accounts might be getting used.
|
||||
// val active = activePubKeyProvider()
|
||||
// if (active != null) {
|
||||
|
||||
+45
-1
@@ -36,6 +36,7 @@ import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
@@ -79,6 +80,10 @@ class NotificationRelayService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "NotificationRelayService"
|
||||
private const val CHANNEL_ID = "notification_relay_service"
|
||||
|
||||
/** Parsed back into `Route.ActiveSubscriptions` by `MainActivity.uriToRoute`. */
|
||||
private const val ACTIVE_SUBSCRIPTIONS_URI = "activesubs"
|
||||
|
||||
private const val NOTIFICATION_ID = 9832
|
||||
|
||||
private const val ACTION_START = "com.vitorpamplona.amethyst.START_NOTIFICATION_SERVICE"
|
||||
@@ -141,6 +146,9 @@ class NotificationRelayService : Service() {
|
||||
private var relayServiceCollectorJob: Job? = null
|
||||
private var connectedRelayCount = 0
|
||||
|
||||
/** Last non-empty per-job breakdown, kept so a reconnect does not blank the expanded view. */
|
||||
private var lastBreakdown: List<String> = emptyList()
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onCreate() {
|
||||
@@ -336,9 +344,12 @@ class NotificationRelayService : Service() {
|
||||
pluralStringRes(this, R.plurals.always_on_notif_connected, connectedRelays, connectedRelays)
|
||||
}
|
||||
|
||||
// Tapping goes to the screen that answers the question the notification raises — "why is it
|
||||
// connected to N relays" — rather than to whatever tab was last open.
|
||||
val openAppIntent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
data = ACTIVE_SUBSCRIPTIONS_URI.toUri()
|
||||
}
|
||||
val pendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
@@ -348,11 +359,44 @@ class NotificationRelayService : Service() {
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
// Expanded only. The collapsed line stays the bare count it has always been — that is all
|
||||
// most people want from an ongoing notification — and the per-job breakdown appears solely
|
||||
// when someone deliberately expands it to ask why the phone is talking to N relays.
|
||||
//
|
||||
// Held across reconnects rather than recomputed blindly: the breakdown is derived from the
|
||||
// *connected* relays, so a drop to zero (the "connecting…" state) would otherwise empty it and
|
||||
// the expanded view would collapse to a single line exactly when someone is most likely
|
||||
// looking at it. What each connection is *for* does not change while it is re-establishing,
|
||||
// so the last known answer is still the right one; only the count above it goes stale, and
|
||||
// that count is already labelled "connecting".
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
val breakdown = fresh.ifEmpty { lastBreakdown }.takeIf { it.isNotEmpty() }
|
||||
|
||||
// Deliberately left ungrouped. This notification is ongoing and IMPORTANCE_LOW, so it
|
||||
// sits in the shade's Silent section next to the low-importance content kinds
|
||||
// (reactions, reposts) — and Android 16 sweeps everything ungrouped in a section into
|
||||
// one aggregate bundle whose summary inherits FLAG_ONGOING_EVENT from any child that
|
||||
// has it, making the whole bundle un-swipeable. Giving this one a group of its own
|
||||
// would not help: a group with a summary but no children, or a child with no summary,
|
||||
// is force-grouped just the same. What keeps content notifications out of that bundle
|
||||
// is that they always post their own group summary (see NotificationUtils), which
|
||||
// leaves this the only ungrouped silent notification we post — one is below the
|
||||
// threshold, so no bundle is formed and nothing gets stapled to it.
|
||||
return NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(getString(R.string.always_on_notif_title))
|
||||
.setContentText(contentText)
|
||||
.setSmallIcon(R.drawable.amethyst_service)
|
||||
.apply {
|
||||
breakdown?.let {
|
||||
setStyle(
|
||||
NotificationCompat
|
||||
.BigTextStyle()
|
||||
.setBigContentTitle(getString(R.string.always_on_notif_title))
|
||||
.bigText(contentText + "\n\n" + it.joinToString("\n")),
|
||||
)
|
||||
}
|
||||
}.setSmallIcon(R.drawable.amethyst_service)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
|
||||
+32
-6
@@ -30,6 +30,8 @@ import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelAndPrune
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.cancelChildlessGroupSummaries
|
||||
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
|
||||
@@ -54,13 +56,35 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
intent: Intent,
|
||||
) {
|
||||
val notificationId = intent.getIntExtra(NotificationUtils.KEY_NOTIFICATION_ID, 0)
|
||||
|
||||
// Whatever the action, the user is done with this notification, so record it before
|
||||
// doing anything else. An enrichment window may still be open on the event (up to
|
||||
// 25s from the first post), and it re-posts the notification every time metadata
|
||||
// lands — without this the notification the user just dealt with comes back, and the
|
||||
// enricher keeps a relay subscription and a wakelock alive for it until the window
|
||||
// elapses. Replies mark it after the send succeeds instead, so a failure leaves the
|
||||
// notification to enrich and retry.
|
||||
val eventId = intent.getStringExtra(NotificationUtils.KEY_EVENT_ID)
|
||||
if (intent.action != NotificationUtils.REPLY_ACTION &&
|
||||
intent.action != NotificationUtils.PUBLIC_REPLY_ACTION &&
|
||||
intent.action != NotificationUtils.MARMOT_REPLY_ACTION
|
||||
) {
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
}
|
||||
|
||||
val notificationManager =
|
||||
ContextCompat.getSystemService(context, NotificationManager::class.java)
|
||||
as NotificationManager
|
||||
|
||||
when (intent.action) {
|
||||
NotificationUtils.MARK_READ_ACTION -> {
|
||||
notificationManager.cancel(notificationId)
|
||||
notificationManager.cancelAndPrune(notificationId)
|
||||
}
|
||||
|
||||
// The user swiped the notification away. It is already gone; all that is left
|
||||
// is to take its group summary with it when it was the last child.
|
||||
NotificationUtils.DISMISS_ACTION -> {
|
||||
notificationManager.cancelChildlessGroupSummaries(alreadyGone = notificationId)
|
||||
}
|
||||
|
||||
NotificationUtils.REPLY_ACTION -> {
|
||||
@@ -78,7 +102,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
if (members.isEmpty()) return
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendReply(accountNpub, members, replyText)
|
||||
}
|
||||
}
|
||||
@@ -95,7 +119,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
|
||||
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendPublicReply(accountNpub, targetEventId, replyText)
|
||||
}
|
||||
}
|
||||
@@ -114,7 +138,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
|
||||
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
|
||||
|
||||
runOnRelay(notificationManager, notificationId) {
|
||||
runOnRelay(notificationManager, notificationId, eventId) {
|
||||
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
|
||||
}
|
||||
}
|
||||
@@ -124,6 +148,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
private fun runOnRelay(
|
||||
notificationManager: NotificationManager,
|
||||
notificationId: Int,
|
||||
eventId: String?,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
val pendingResult = goAsync()
|
||||
@@ -138,7 +163,8 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
|
||||
try {
|
||||
block()
|
||||
notificationManager.cancel(notificationId)
|
||||
eventId?.let { NotificationUtils.markDismissed(it) }
|
||||
notificationManager.cancelAndPrune(notificationId)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
|
||||
@@ -189,7 +215,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
|
||||
persistOwn = false,
|
||||
)
|
||||
|
||||
account.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmotGroupRelays(nostrGroupId))
|
||||
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
|
||||
}
|
||||
|
||||
private suspend fun sendPublicReply(
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
/**
|
||||
* The per-job breakdown behind the always-on notification's relay count.
|
||||
*
|
||||
* **Only ever shown expanded.** The collapsed line stays exactly what it was — a count — because
|
||||
* that is all most people ever want from an ongoing notification. This is for the moment someone
|
||||
* taps to ask *why* their phone is talking to 40 relays.
|
||||
*
|
||||
* A relay usually serves several jobs at once (measured: a typical relay carries four), so these
|
||||
* counts deliberately **overlap and sum to more than the relay count**. They answer "how many relays
|
||||
* carry my DMs", not "how is the pool partitioned" — there is no partition.
|
||||
*
|
||||
* Purposes with no label — feeds and whatever screen is open — collapse into one "browsing" line.
|
||||
* Those disappear on their own once the app is backgrounded, which is exactly when this notification
|
||||
* matters most, so spelling them out would add noise precisely when the user is least interested.
|
||||
*/
|
||||
object RelayPurposeSummary {
|
||||
/**
|
||||
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
|
||||
* the caller must then fall back to the bare count rather than render an empty section.
|
||||
*/
|
||||
fun lines(ctx: Context): List<String> {
|
||||
val client = Amethyst.instance.client
|
||||
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
|
||||
val browsing = mutableSetOf<NormalizedRelayUrl>()
|
||||
|
||||
client.connectedRelaysFlow().value.forEach { relay ->
|
||||
client
|
||||
.activeRequests(relay)
|
||||
.values
|
||||
.flatten()
|
||||
.purposes()
|
||||
.forEach { purpose ->
|
||||
if (SubPurposeLabels.isWorthNamingInNotification(purpose)) {
|
||||
named.getOrPut(purpose) { mutableSetOf() }.add(relay)
|
||||
} else {
|
||||
browsing.add(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val lines =
|
||||
named.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
|
||||
.map { (purpose, relays) ->
|
||||
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
|
||||
}.toMutableList()
|
||||
|
||||
if (browsing.isNotEmpty()) {
|
||||
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
|
||||
/**
|
||||
* A subscription query state that belongs to one logged-in account.
|
||||
*
|
||||
* Around 66 query-state classes already carry an `account`, but nothing tied them together, so a
|
||||
* subscription manager could not ask "whose subscription is this?" without knowing the concrete
|
||||
* type. That is why the Active Relay Subscriptions screen filed the home feed under "not attributed"
|
||||
* despite it being built from one specific person's follow list: the base manager checked for
|
||||
* `AccountQueryState` and the home feed uses `HomeQueryState`.
|
||||
*
|
||||
* Implement this on any query state whose subscriptions belong to a single account, and the base
|
||||
* managers attribute them automatically.
|
||||
*/
|
||||
interface AccountScopedQuery {
|
||||
val account: Account
|
||||
}
|
||||
+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
|
||||
}
|
||||
+19
-2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEByKey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -76,7 +78,7 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -114,7 +116,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
|
||||
uniqueSubscribedAccounts.forEach {
|
||||
val mainKey = id(it)
|
||||
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
|
||||
val newFilters =
|
||||
updateFilter(it, since(it))
|
||||
?.ifEmpty { null }
|
||||
// Attribute to the account that owns this subscription, once, here — rather than
|
||||
// threading a pubkey through every filter builder underneath. Builders that already
|
||||
// know their account keep what they set.
|
||||
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
|
||||
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
|
||||
|
||||
updated.add(mainKey)
|
||||
@@ -131,4 +139,13 @@ abstract class PerUniqueIdEoseManager<T, U : Any>(
|
||||
): List<RelayBasedFilter>?
|
||||
|
||||
abstract fun id(key: T): U
|
||||
|
||||
/**
|
||||
* The account behind [key], when the key is account-scoped. Null for keys about other users.
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
*/
|
||||
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
}
|
||||
|
||||
+19
-2
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountKey
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -88,7 +90,7 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -127,7 +129,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
uniqueSubscribedAccounts.forEach {
|
||||
val user = user(it)
|
||||
val sub = findOrCreateSubFor(it)
|
||||
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
|
||||
val newFilters =
|
||||
updateFilter(it, since(it))
|
||||
?.ifEmpty { null }
|
||||
// Attribute to the account that owns this subscription, once, here — rather than
|
||||
// threading a pubkey through every filter builder underneath. Builders that already
|
||||
// know their account keep what they set.
|
||||
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
|
||||
sub.updateFilters(newFilters?.groupByRelay())
|
||||
updated.add(user)
|
||||
}
|
||||
@@ -145,4 +153,13 @@ abstract class PerUserAndFollowListEoseManager<T, U : Any>(
|
||||
abstract fun user(key: T): User
|
||||
|
||||
abstract fun list(key: T): U
|
||||
|
||||
/**
|
||||
* The account behind [key], when the key is account-scoped. Null for keys about other users.
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
*/
|
||||
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
}
|
||||
|
||||
+19
-2
@@ -21,7 +21,9 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.eoseManagers
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.BaseEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.attributedTo
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -75,7 +77,7 @@ abstract class PerUserEoseManager<T>(
|
||||
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -113,7 +115,13 @@ abstract class PerUserEoseManager<T>(
|
||||
|
||||
uniqueSubscribedAccounts.forEach {
|
||||
val user = user(it)
|
||||
val newFilters = updateFilter(it, since(it))?.ifEmpty { null }
|
||||
val newFilters =
|
||||
updateFilter(it, since(it))
|
||||
?.ifEmpty { null }
|
||||
// Attribute to the account that owns this subscription, once, here — rather than
|
||||
// threading a pubkey through every filter builder underneath. Builders that already
|
||||
// know their account keep what they set.
|
||||
?.let { f -> accountPubKeyOf(it)?.let { pk -> f.attributedTo(pk) } ?: f }
|
||||
|
||||
findOrCreateSubFor(it).updateFilters(newFilters?.groupByRelay())
|
||||
|
||||
@@ -131,4 +139,13 @@ abstract class PerUserEoseManager<T>(
|
||||
): List<RelayBasedFilter>?
|
||||
|
||||
abstract fun user(key: T): User
|
||||
|
||||
/**
|
||||
* The account behind [key], when the key is account-scoped. Null for keys about other users.
|
||||
*
|
||||
* Keyed on [AccountScopedQuery] rather than a concrete query-state type: the home feed uses
|
||||
* HomeQueryState, notifications use AccountQueryState, and checking one concrete class filed the
|
||||
* other under "not attributed" despite both being built from a single account's data.
|
||||
*/
|
||||
private fun accountPubKeyOf(key: Any?): String? = (key as? AccountScopedQuery)?.account?.userProfile()?.pubkeyHex
|
||||
}
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ class NotifyCoordinator(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIncomingMessage(
|
||||
override suspend fun onIncomingMessage(
|
||||
relay: IRelayClient,
|
||||
msgStr: String,
|
||||
msg: Message,
|
||||
|
||||
+5
-6
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuWalletFilterAssembler
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
|
||||
@@ -79,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.OnePodc
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastEpisodesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.podcasts.datasource.PodcastsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.datasource.PollsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.polls.results.datasources.PollResponsesFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.products.datasource.ProductsFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.publicChats.datasource.PublicChatsFilterAssembler
|
||||
@@ -168,6 +168,9 @@ class RelaySubscriptionsCoordinator(
|
||||
val chess = ChessFilterAssembler(client)
|
||||
|
||||
val polls = PollsFilterAssembler(client)
|
||||
|
||||
// Votes for the poll whose results screen is open.
|
||||
val pollResponses = PollResponsesFilterAssembler(client)
|
||||
val pictures = PicturesFilterAssembler(client)
|
||||
val workouts = WorkoutsFilterAssembler(client)
|
||||
val gitRepositories = GitRepositoriesFilterAssembler(client)
|
||||
@@ -204,10 +207,6 @@ class RelaySubscriptionsCoordinator(
|
||||
// active when the wallet's on-chain transactions screen is on top.
|
||||
val onchainZaps = OnchainZapsFilterAssembler(client)
|
||||
|
||||
// active when a NIP-60 Cashu wallet exists for the account.
|
||||
// Subscribes to kinds 17375/7375/7376/7374/10019 by author + inbound 9321 #p=self.
|
||||
val cashuWallet = CashuWalletFilterAssembler(client)
|
||||
|
||||
// active while the user is browsing the NIP-87 mint picker. Subscribes to
|
||||
// kind:38172 cashu mint announcements + kind:38000 cashu-scoped
|
||||
// recommendations on the configured relay set.
|
||||
@@ -234,6 +233,7 @@ class RelaySubscriptionsCoordinator(
|
||||
video,
|
||||
discovery,
|
||||
polls,
|
||||
pollResponses,
|
||||
pictures,
|
||||
workouts,
|
||||
gitRepositories,
|
||||
@@ -279,7 +279,6 @@ class RelaySubscriptionsCoordinator(
|
||||
chess,
|
||||
nwc,
|
||||
onchainZaps,
|
||||
cashuWallet,
|
||||
cashuMintDirectory,
|
||||
)
|
||||
|
||||
|
||||
+65
-12
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.relayClient.AccountScopedQuery
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts.AccountDraftsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot.MarmotGroupEventsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata.AccountMetadataEoseManager
|
||||
@@ -31,17 +32,48 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01No
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip47WalletConnect.NwcNotificationsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsHistoryEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip60Cashu.CashuWalletEoseManager
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
|
||||
// This allows multiple screen to be listening to logged-in accounts.
|
||||
//
|
||||
// Carries only what an account can supply with no screen attached, so the
|
||||
// background registry can mount the always-on loaders for accounts the user
|
||||
// opted into keeping active while the app is away. Screens use the richer
|
||||
// [AccountUiQueryState] below.
|
||||
@Stable
|
||||
class AccountQueryState(
|
||||
val account: Account,
|
||||
val feedContentStates: AccountFeedContentStates,
|
||||
open class AccountQueryState(
|
||||
override val account: Account,
|
||||
val otherAccounts: Set<HexKey>,
|
||||
)
|
||||
) : AccountScopedQuery {
|
||||
/**
|
||||
* The feeds this account renders, when a screen is attached — null for
|
||||
* background accounts.
|
||||
*
|
||||
* The only always-on reader is
|
||||
* [com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager],
|
||||
* which reads it as a cold-start `since` floor via `lastNoteCreatedAtIfFilled()`.
|
||||
* That floor only arms once the feed holds a full page, and nothing fills a
|
||||
* feed that has no UI — so for a background account it could only ever
|
||||
* return null anyway. Leaving the field off the background key states that
|
||||
* rather than pretending there is a feed to consult.
|
||||
*/
|
||||
open val feedContentStates: AccountFeedContentStates? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* The key for an account with a screen attached. Adds the feed states, which
|
||||
* lets the notification loaders floor their cold-start queries at the depth the
|
||||
* rendered feed already reaches instead of asking all-time again.
|
||||
*/
|
||||
@Stable
|
||||
class AccountUiQueryState(
|
||||
account: Account,
|
||||
override val feedContentStates: AccountFeedContentStates,
|
||||
otherAccounts: Set<HexKey>,
|
||||
) : AccountQueryState(account, otherAccounts)
|
||||
|
||||
/**
|
||||
* Always-on account loaders: metadata, gift wraps, drafts, inbox-relay
|
||||
@@ -53,30 +85,51 @@ class AccountFilterAssembler(
|
||||
client: INostrClient,
|
||||
) : ComposeSubscriptionManager<AccountQueryState>() {
|
||||
// Live tail: the recent week of gift wraps, always open at the top for new messages.
|
||||
val giftWraps = AccountGiftWrapsEoseManager(client, ::allKeys)
|
||||
val giftWraps = AccountGiftWrapsEoseManager(client, ::preferredKeys)
|
||||
|
||||
// History: older gift wraps, loaded on demand in bounded one-shot slices.
|
||||
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::allKeys)
|
||||
val giftWrapsHistory = AccountGiftWrapsHistoryEoseManager(client, ::preferredKeys)
|
||||
|
||||
// Live tail: the recent week of notifications from the inbox + group host relays.
|
||||
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys)
|
||||
val notifications = AccountNotificationsEoseFromInboxRelaysManager(client, ::preferredKeys)
|
||||
|
||||
// History: older notifications, paged backward by until+limit per relay, driven by the feed's markers.
|
||||
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::allKeys)
|
||||
val notificationsHistory = AccountNotificationsHistoryEoseManager(client, ::preferredKeys)
|
||||
|
||||
val group =
|
||||
listOf(
|
||||
AccountMetadataEoseManager(client, ::allKeys),
|
||||
AccountMetadataEoseManager(client, ::preferredKeys),
|
||||
giftWraps,
|
||||
giftWrapsHistory,
|
||||
AccountDraftsEoseManager(client, ::allKeys),
|
||||
AccountDraftsEoseManager(client, ::preferredKeys),
|
||||
notifications,
|
||||
notificationsHistory,
|
||||
// Live tail: NIP-47 wallet notifications (payment_received) on each connected wallet's own relay.
|
||||
NwcNotificationsEoseManager(client, ::allKeys),
|
||||
MarmotGroupEventsEoseManager(client, ::allKeys),
|
||||
NwcNotificationsEoseManager(client, ::preferredKeys),
|
||||
// NIP-60 wallet + NIP-61 nutzap inbox. Mounted here rather than run from a collector
|
||||
// inside CashuWalletState, so it starts and stops with every other account-level loader.
|
||||
CashuWalletEoseManager(client, ::preferredKeys),
|
||||
MarmotGroupEventsEoseManager(client, ::preferredKeys),
|
||||
)
|
||||
|
||||
/**
|
||||
* One key per account, preferring a screen's [AccountUiQueryState] over the
|
||||
* background registry's key.
|
||||
*
|
||||
* An account can be mounted twice — the user is looking at it *and* asked to keep
|
||||
* it running in the background. The managers below all dedup by user, but by
|
||||
* keeping whichever key they meet first, which is just whoever mounted first.
|
||||
* Resolving it here means the account being looked at keeps the feed-backed
|
||||
* cold-start floor instead of losing it to a race.
|
||||
*/
|
||||
private fun preferredKeys(): Set<AccountQueryState> =
|
||||
allKeys()
|
||||
.groupBy { it.account.userProfile().pubkeyHex }
|
||||
.values
|
||||
.mapTo(mutableSetOf()) { keys ->
|
||||
keys.firstOrNull { it.feedContentStates != null } ?: keys.first()
|
||||
}
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ fun AccountFilterAssemblerSubscription(
|
||||
// even if they are tracking the same tag.
|
||||
val state =
|
||||
remember(accountViewModel) {
|
||||
AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
|
||||
AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
|
||||
}
|
||||
|
||||
KeyDataSourceSubscription(state, dataSource)
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ class AccountForegroundFilterAssembler(
|
||||
authenticator: IAuthStatus,
|
||||
failureTracker: RelayOfflineTracker,
|
||||
scope: CoroutineScope,
|
||||
) : ComposeSubscriptionManager<AccountQueryState>() {
|
||||
) : ComposeSubscriptionManager<AccountUiQueryState>() {
|
||||
val group =
|
||||
listOf(
|
||||
AccountFollowsLoaderSubAssembler(client, cache, scope, authenticator, failureTracker, ::allKeys),
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ fun AccountForegroundFilterAssemblerSubscription(
|
||||
) {
|
||||
val state =
|
||||
remember(accountViewModel) {
|
||||
AccountQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
|
||||
AccountUiQueryState(accountViewModel.account, accountViewModel.feedStates, accountViewModel.trustedAccounts.value)
|
||||
}
|
||||
|
||||
LifecycleAwareKeyDataSourceSubscription(state, dataSource)
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account
|
||||
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
* Mounts the account-level loaders — notifications, DMs, gift wraps, drafts,
|
||||
* metadata — for accounts that have no screen of their own.
|
||||
*
|
||||
* Every other mount of [AccountFilterAssembler] comes from a composable holding an
|
||||
* `AccountViewModel`, which means it only ever covers the account the user is
|
||||
* looking at. Every other logged-in account was merely resident in memory so pushed
|
||||
* gift wraps could be decrypted by their owner; everything else about them depended
|
||||
* on a push message arriving. On a device with no push (no Play Services, no
|
||||
* UnifiedPush distributor, Pokey not installed) they pulled nothing at all.
|
||||
*
|
||||
* This registry is the pull side. It holds one [AccountQueryState] per account it is
|
||||
* given and drives [AccountFilterAssembler.subscribe] /
|
||||
* [AccountFilterAssembler.unsubscribe] directly — those are plain functions, not
|
||||
* composables, so no UI has to exist.
|
||||
*
|
||||
* The keys carry no feed states (see [AccountQueryState.feedContentStates]) and no
|
||||
* `otherAccounts` — populating the account switcher's avatars is a screen's job, and
|
||||
* these accounts have no screen.
|
||||
*
|
||||
* It deliberately decides nothing about *which* accounts those are: it mounts exactly
|
||||
* the set it is handed, so the foreground/background rule lives in one place, in
|
||||
* [com.vitorpamplona.amethyst.service.notifications.AlwaysOnNotificationServiceManager],
|
||||
* which already watches the switches that define it and calls [sync] on every change.
|
||||
*/
|
||||
class AccountSubscriptionRegistry(
|
||||
private val assembler: AccountFilterAssembler,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AccountSubscriptions"
|
||||
}
|
||||
|
||||
private val mounted = mutableMapOf<HexKey, AccountQueryState>()
|
||||
|
||||
/**
|
||||
* Makes the mounted set match [accounts] exactly: subscribes the ones that just
|
||||
* joined it, unsubscribes the ones that left or were unloaded, and leaves the
|
||||
* rest untouched.
|
||||
*
|
||||
* Idempotent, so callers can hand it the same set on every emission of the flows
|
||||
* behind it without churning subscriptions.
|
||||
*/
|
||||
@Synchronized
|
||||
fun sync(accounts: Collection<Account>) {
|
||||
val wanted = accounts.associateBy { it.userProfile().pubkeyHex }
|
||||
|
||||
// Unmount accounts that dropped out, and accounts whose Account object was
|
||||
// replaced (re-login rebuilds it) — the stale instance holds the old
|
||||
// signer and relay lists, so its filters would be built from dead state.
|
||||
val stale = mounted.filter { (pubkey, state) -> wanted[pubkey] !== state.account }
|
||||
stale.forEach { (pubkey, state) ->
|
||||
mounted.remove(pubkey)
|
||||
assembler.unsubscribe(state)
|
||||
}
|
||||
|
||||
var added = 0
|
||||
wanted.forEach { (pubkey, account) ->
|
||||
if (pubkey !in mounted) {
|
||||
val state = AccountQueryState(account, emptySet())
|
||||
mounted[pubkey] = state
|
||||
assembler.subscribe(state)
|
||||
added++
|
||||
}
|
||||
}
|
||||
|
||||
if (added > 0 || stale.isNotEmpty()) {
|
||||
Log.d(TAG) { "Account subscriptions: ${mounted.size} mounted (+$added, -${stale.size})" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Unmounts everything. Used when the app goes away with the master off, and on logout. */
|
||||
@Synchronized
|
||||
fun clear() = sync(emptyList())
|
||||
}
|
||||
+4
-2
@@ -20,9 +20,10 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.drafts
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
|
||||
|
||||
@@ -42,7 +43,8 @@ fun filterDraftsFromKey(
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter =
|
||||
Filter(
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.ACCOUNT_DATA,
|
||||
kinds = DraftKinds,
|
||||
authors = listOf(pubkey),
|
||||
since = since,
|
||||
|
||||
+22
-5
@@ -21,12 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.follows
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.IEoseManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.BundledUpdate
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountUiQueryState
|
||||
import com.vitorpamplona.amethyst.service.relays.EOSEAccountFast
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||
@@ -60,7 +62,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
val scope: CoroutineScope,
|
||||
val authStatus: IAuthStatus,
|
||||
val failureTracker: RelayOfflineTracker,
|
||||
val allKeys: () -> Set<AccountQueryState>,
|
||||
val allKeys: () -> Set<AccountUiQueryState>,
|
||||
) : IEoseManager {
|
||||
private val logTag = "AccountFollowsLoaderSubAssembler"
|
||||
private val orchestrator = SubscriptionController(client)
|
||||
@@ -114,7 +116,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
newEose(TimeUtils.now(), relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEvent(
|
||||
override suspend fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
@@ -157,6 +159,15 @@ class AccountFollowsLoaderSubAssembler(
|
||||
|
||||
val connectedRelays = client.connectedRelaysFlow().value
|
||||
|
||||
// Attributed only when one account is asking. The follow lists above are unioned across every
|
||||
// logged-in account and the relays are then picked from that union, so with several accounts
|
||||
// active no single one owns a given filter. Deduped by pubkey because `Account` uses identity
|
||||
// equality.
|
||||
val soleAccountPubKey =
|
||||
accounts
|
||||
.mapTo(mutableSetOf()) { it.userProfile().pubkeyHex }
|
||||
.singleOrNull()
|
||||
|
||||
val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried)
|
||||
|
||||
hasTried.removeEveryoneBut(users)
|
||||
@@ -165,7 +176,13 @@ class AccountFollowsLoaderSubAssembler(
|
||||
if (users.isNotEmpty()) {
|
||||
RelayBasedFilter(
|
||||
relay = relay,
|
||||
filter = Filter(kinds = listOf(AdvertisedRelayListEvent.KIND), authors = users.sorted()),
|
||||
filter =
|
||||
ExplainedFilter(
|
||||
purpose = SubPurpose.RELAY_LISTS,
|
||||
kinds = listOf(AdvertisedRelayListEvent.KIND),
|
||||
authors = users.sorted(),
|
||||
accountPubKeys = listOfNotNull(soleAccountPubKey),
|
||||
),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -173,7 +190,7 @@ class AccountFollowsLoaderSubAssembler(
|
||||
}
|
||||
}
|
||||
|
||||
fun updateSubscriptions(keys: Set<AccountQueryState>) {
|
||||
fun updateSubscriptions(keys: Set<AccountUiQueryState>) {
|
||||
val uniqueSubscribedAccounts = keys.associate { it.account.userProfile() to it.account }
|
||||
|
||||
val allFilters = updateFilterForAllAccounts(uniqueSubscribedAccounts.values)
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -21,6 +21,8 @@
|
||||
package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager
|
||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.launchChatFeedToggleObserver
|
||||
@@ -89,7 +91,7 @@ class MarmotGroupEventsEoseManager(
|
||||
"(metadataRelays=${groupRelays?.size ?: 0}, usingFallback=${groupRelays.isNullOrEmpty()}): ${relaysForGroup.map { it.url }}"
|
||||
}
|
||||
for (relay in relaysForGroup) {
|
||||
result.add(RelayBasedFilter(relay = relay, filter = filter))
|
||||
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(filter, SubPurpose.ENCRYPTED_GROUPS, "MLS group messages", entityIds = listOf(groupId))))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +99,7 @@ class MarmotGroupEventsEoseManager(
|
||||
if (fallbackRelays.isNotEmpty()) {
|
||||
val ownKeyPackageFilter = manager.subscriptionManager.ownKeyPackageFilter()
|
||||
for (relay in fallbackRelays) {
|
||||
result.add(RelayBasedFilter(relay = relay, filter = ownKeyPackageFilter))
|
||||
result.add(RelayBasedFilter(relay = relay, filter = ExplainedFilter.of(ownKeyPackageFilter, SubPurpose.ENCRYPTED_GROUPS, "own MLS key package")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user